Skip to main content

iris_chat_protocol/
storage.rs

1mod checkpoint;
2mod checkpoint_index;
3mod checkpoint_sqlite;
4#[cfg(test)]
5mod checkpoint_tests;
6mod segmented;
7#[cfg(test)]
8mod segmented_tests;
9
10pub use checkpoint::{StorageCheckpoint, StorageCheckpointBody};
11
12#[cfg(test)]
13pub(crate) fn checkpoint_io_bytes() -> (usize, usize) {
14    segmented::io_bytes()
15}
16
17use super::SharedConnection;
18use std::collections::HashMap;
19use std::fs;
20use std::io::Write;
21use std::path::PathBuf;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct StorageError {
27    message: String,
28    interrupted: bool,
29}
30
31impl StorageError {
32    pub fn new(message: impl Into<String>) -> Self {
33        Self {
34            message: message.into(),
35            interrupted: false,
36        }
37    }
38
39    /// True only for a SQLite operation interrupted by its caller, never for
40    /// malformed records or an arbitrary error message with the same wording.
41    pub fn is_interrupted(&self) -> bool {
42        self.interrupted
43    }
44}
45
46impl std::fmt::Display for StorageError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        self.message.fmt(f)
49    }
50}
51
52impl std::error::Error for StorageError {}
53
54pub type StorageResult<T> = Result<T, StorageError>;
55
56pub trait StorageAdapter: Send + Sync {
57    fn get(&self, key: &str) -> StorageResult<Option<String>>;
58    fn put(&self, key: &str, value: String) -> StorageResult<()>;
59    /// Preserve a migration input before a later checkpoint can replace it.
60    /// Buffered adapters must commit this write before returning success.
61    fn put_preserved(&self, key: &str, value: String) -> StorageResult<()> {
62        self.put(key, value)
63    }
64    fn put_checkpoint(&self, key: &str, value: &StorageCheckpoint) -> StorageResult<()> {
65        self.put(key, value.materialize()?)
66    }
67    fn del(&self, key: &str) -> StorageResult<()>;
68    fn list(&self, prefix: &str) -> StorageResult<Vec<String>>;
69}
70
71#[derive(Clone)]
72pub struct InMemoryStorage {
73    store: Arc<Mutex<HashMap<String, String>>>,
74}
75
76impl InMemoryStorage {
77    pub fn new() -> Self {
78        Self {
79            store: Arc::new(Mutex::new(HashMap::new())),
80        }
81    }
82}
83
84impl Default for InMemoryStorage {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl StorageAdapter for InMemoryStorage {
91    fn get(&self, key: &str) -> StorageResult<Option<String>> {
92        let store = self
93            .store
94            .lock()
95            .map_err(|_| StorageError::new("storage mutex poisoned"))?;
96        Ok(store.get(key).cloned())
97    }
98
99    fn put(&self, key: &str, value: String) -> StorageResult<()> {
100        let mut store = self
101            .store
102            .lock()
103            .map_err(|_| StorageError::new("storage mutex poisoned"))?;
104        store.insert(key.to_string(), value);
105        Ok(())
106    }
107
108    fn del(&self, key: &str) -> StorageResult<()> {
109        let mut store = self
110            .store
111            .lock()
112            .map_err(|_| StorageError::new("storage mutex poisoned"))?;
113        store.remove(key);
114        Ok(())
115    }
116
117    fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
118        let store = self
119            .store
120            .lock()
121            .map_err(|_| StorageError::new("storage mutex poisoned"))?;
122        Ok(store
123            .keys()
124            .filter(|key| key.starts_with(prefix))
125            .cloned()
126            .collect())
127    }
128}
129
130pub struct FileStorageAdapter {
131    base_path: PathBuf,
132}
133
134impl FileStorageAdapter {
135    /// Opens a dedicated directory for secret session state. On Unix, access
136    /// to this directory is restricted to the current user, including on reopen.
137    pub fn new(base_path: PathBuf) -> StorageResult<Self> {
138        let mut builder = fs::DirBuilder::new();
139        builder.recursive(true);
140        #[cfg(unix)]
141        {
142            use std::os::unix::fs::DirBuilderExt;
143            builder.mode(0o700);
144        }
145        builder
146            .create(&base_path)
147            .map_err(|err| storage_io_error("failed to create storage directory", err))?;
148        // Existing installations may contain session keys in readable files.
149        // Restrict their enclosing directory before loading any state.
150        #[cfg(unix)]
151        {
152            use std::os::unix::fs::PermissionsExt;
153            fs::set_permissions(&base_path, fs::Permissions::from_mode(0o700))
154                .map_err(|err| storage_io_error("failed to protect storage directory", err))?;
155        }
156        Ok(Self { base_path })
157    }
158
159    fn sanitize_key(key: &str) -> String {
160        key.replace(['/', '\\', ':'], "_")
161    }
162
163    fn key_to_path(&self, key: &str) -> PathBuf {
164        let sanitized = Self::sanitize_key(key);
165        self.base_path.join(format!("{}.json", sanitized))
166    }
167}
168
169impl StorageAdapter for FileStorageAdapter {
170    fn get(&self, key: &str) -> StorageResult<Option<String>> {
171        let path = self.key_to_path(key);
172        match fs::read_to_string(&path) {
173            Ok(contents) => Ok(Some(contents)),
174            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
175            Err(err) => Err(storage_io_error("failed to read storage file", err)),
176        }
177    }
178
179    fn put(&self, key: &str, value: String) -> StorageResult<()> {
180        let path = self.key_to_path(key);
181
182        let tmp_path = path.with_extension(format!("json.{}.tmp", rand::random::<u128>()));
183        let mut options = fs::OpenOptions::new();
184        options.write(true).create_new(true);
185        #[cfg(unix)]
186        {
187            use std::os::unix::fs::OpenOptionsExt;
188            options.mode(0o600);
189        }
190        // Set the mode at creation: chmod after writing would expose secrets
191        // briefly, and exclusive creation also refuses existing symlinks.
192        let mut file = options
193            .open(&tmp_path)
194            .map_err(|err| storage_io_error("failed to create storage temp file", err))?;
195        if let Err(err) = file.write_all(value.as_bytes()) {
196            drop(file);
197            let _ = fs::remove_file(&tmp_path);
198            return Err(storage_io_error("failed to write storage temp file", err));
199        }
200        drop(file);
201
202        #[cfg(windows)]
203        {
204            if path.exists() {
205                fs::remove_file(&path).map_err(|err| {
206                    storage_io_error("failed to replace existing storage file", err)
207                })?;
208            }
209        }
210
211        fs::rename(&tmp_path, &path)
212            .map_err(|err| storage_io_error("failed to commit storage file", err))?;
213
214        Ok(())
215    }
216
217    fn put_preserved(&self, key: &str, value: String) -> StorageResult<()> {
218        self.put(key, value)?;
219        // Ordinary checkpoints remain buffered. A migration archive must be
220        // acknowledged by storage before a later checkpoint can replace it.
221        fs::OpenOptions::new()
222            .read(true)
223            .write(true)
224            .open(self.key_to_path(key))
225            .and_then(|file| file.sync_all())
226            .map_err(|err| storage_io_error("failed to sync preserved storage file", err))?;
227        #[cfg(unix)]
228        fs::File::open(&self.base_path)
229            .and_then(|directory| directory.sync_all())
230            .map_err(|err| storage_io_error("failed to sync preserved storage directory", err))?;
231        Ok(())
232    }
233
234    fn del(&self, key: &str) -> StorageResult<()> {
235        let path = self.key_to_path(key);
236        match fs::remove_file(&path) {
237            Ok(()) => Ok(()),
238            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
239            Err(err) => Err(storage_io_error("failed to delete storage file", err)),
240        }
241    }
242
243    fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
244        let mut keys = Vec::new();
245        let sanitized_prefix = Self::sanitize_key(prefix);
246        let entries = fs::read_dir(&self.base_path)
247            .map_err(|err| storage_io_error("failed to read storage directory", err))?;
248
249        for entry in entries {
250            let entry =
251                entry.map_err(|err| storage_io_error("failed to read storage entry", err))?;
252            let file_name = entry.file_name();
253            let file_name_str = file_name.to_string_lossy();
254
255            if !file_name_str.ends_with(".json") {
256                continue;
257            }
258
259            let key = file_name_str
260                .strip_suffix(".json")
261                .unwrap_or(&file_name_str)
262                .to_string();
263
264            if prefix.is_empty() {
265                keys.push(key);
266                continue;
267            }
268
269            if key.starts_with(&sanitized_prefix) {
270                let remainder = key.strip_prefix(&sanitized_prefix).unwrap_or("");
271                keys.push(format!("{}{}", prefix, remainder));
272            }
273        }
274
275        Ok(keys)
276    }
277}
278
279pub struct DebouncedFileStorage {
280    adapter: FileStorageAdapter,
281    pending_writes: Mutex<HashMap<String, String>>,
282    last_flush: Mutex<Instant>,
283    flush_interval: Duration,
284}
285
286impl DebouncedFileStorage {
287    pub fn new(base_path: PathBuf, flush_interval_ms: u64) -> StorageResult<Self> {
288        Ok(Self {
289            adapter: FileStorageAdapter::new(base_path)?,
290            pending_writes: Mutex::new(HashMap::new()),
291            last_flush: Mutex::new(Instant::now()),
292            flush_interval: Duration::from_millis(flush_interval_ms),
293        })
294    }
295
296    pub fn flush(&self) -> StorageResult<()> {
297        let mut pending = self
298            .pending_writes
299            .lock()
300            .map_err(|_| StorageError::new("pending file storage mutex poisoned"))?;
301        for (key, value) in pending.drain() {
302            self.adapter.put(&key, value)?;
303        }
304        *self
305            .last_flush
306            .lock()
307            .map_err(|_| StorageError::new("file storage flush mutex poisoned"))? = Instant::now();
308        Ok(())
309    }
310
311    fn maybe_flush(&self) -> StorageResult<()> {
312        let last_flush = *self
313            .last_flush
314            .lock()
315            .map_err(|_| StorageError::new("file storage flush mutex poisoned"))?;
316        let pending_count = self
317            .pending_writes
318            .lock()
319            .map_err(|_| StorageError::new("pending file storage mutex poisoned"))?
320            .len();
321
322        if last_flush.elapsed() >= self.flush_interval && pending_count > 0 {
323            self.flush()?;
324        }
325        Ok(())
326    }
327}
328
329impl StorageAdapter for DebouncedFileStorage {
330    fn get(&self, key: &str) -> StorageResult<Option<String>> {
331        let pending = self
332            .pending_writes
333            .lock()
334            .map_err(|_| StorageError::new("pending file storage mutex poisoned"))?;
335        if let Some(value) = pending.get(key) {
336            return Ok(Some(value.clone()));
337        }
338        drop(pending);
339        self.adapter.get(key)
340    }
341
342    fn put(&self, key: &str, value: String) -> StorageResult<()> {
343        self.pending_writes
344            .lock()
345            .map_err(|_| StorageError::new("pending file storage mutex poisoned"))?
346            .insert(key.to_string(), value);
347        self.maybe_flush()
348    }
349
350    fn put_preserved(&self, key: &str, value: String) -> StorageResult<()> {
351        let mut pending = self
352            .pending_writes
353            .lock()
354            .map_err(|_| StorageError::new("pending file storage mutex poisoned"))?;
355        self.adapter.put_preserved(key, value)?;
356        pending.remove(key);
357        Ok(())
358    }
359
360    fn del(&self, key: &str) -> StorageResult<()> {
361        self.pending_writes
362            .lock()
363            .map_err(|_| StorageError::new("pending file storage mutex poisoned"))?
364            .remove(key);
365        self.adapter.del(key)
366    }
367
368    fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
369        let mut keys = self.adapter.list(prefix)?;
370        let pending = self
371            .pending_writes
372            .lock()
373            .map_err(|_| StorageError::new("pending file storage mutex poisoned"))?;
374
375        for key in pending.keys() {
376            if key.starts_with(prefix) && !keys.contains(key) {
377                keys.push(key.clone());
378            }
379        }
380
381        Ok(keys)
382    }
383}
384
385/// SQLite-backed implementation of `iris_chat_protocol::StorageAdapter`.
386/// Keys are namespaced by (owner_pubkey_hex, device_pubkey_hex) so a
387/// single database serves multiple owner accounts and devices without
388/// keyspace collisions, matching the per-(owner, device) directory
389/// scoping the previous file-backed adapter used.
390pub struct SqliteStorageAdapter {
391    conn: SharedConnection,
392    owner_pubkey_hex: String,
393    device_pubkey_hex: String,
394}
395
396impl SqliteStorageAdapter {
397    pub fn new(
398        conn: SharedConnection,
399        owner_pubkey_hex: String,
400        device_pubkey_hex: String,
401    ) -> Self {
402        Self {
403            conn,
404            owner_pubkey_hex,
405            device_pubkey_hex,
406        }
407    }
408
409    fn map_err<E: std::fmt::Display + std::any::Any>(error: E) -> StorageError {
410        let interrupted = matches!(
411            (&error as &dyn std::any::Any).downcast_ref::<rusqlite::Error>(),
412            Some(rusqlite::Error::SqliteFailure(inner, _))
413                if inner.code == rusqlite::ErrorCode::OperationInterrupted
414        );
415        StorageError {
416            message: error.to_string(),
417            interrupted,
418        }
419    }
420}
421
422impl StorageAdapter for SqliteStorageAdapter {
423    fn get(&self, key: &str) -> StorageResult<Option<String>> {
424        let mut conn = self
425            .conn
426            .lock()
427            .map_err(|_| StorageError::new("ndr_kv connection mutex poisoned"))?;
428        segmented::get(
429            &mut conn,
430            (&self.owner_pubkey_hex, &self.device_pubkey_hex, key),
431        )
432    }
433
434    fn put(&self, key: &str, value: String) -> StorageResult<()> {
435        let mut conn = self
436            .conn
437            .lock()
438            .map_err(|_| StorageError::new("ndr_kv connection mutex poisoned"))?;
439        segmented::put(
440            &mut conn,
441            (&self.owner_pubkey_hex, &self.device_pubkey_hex, key),
442            &value,
443        )
444    }
445
446    fn put_checkpoint(&self, key: &str, value: &StorageCheckpoint) -> StorageResult<()> {
447        // Keep small values on the existing simple path. Large checkpoints
448        // use independently addressed records, with no full-value mirror.
449        if value.len() < 64 * 1024 {
450            return self.put(key, value.materialize()?);
451        }
452        let mut conn = self
453            .conn
454            .lock()
455            .map_err(|_| StorageError::new("ndr_kv connection mutex poisoned"))?;
456        checkpoint_sqlite::put(
457            &mut conn,
458            (&self.owner_pubkey_hex, &self.device_pubkey_hex, key),
459            value,
460        )
461    }
462
463    fn del(&self, key: &str) -> StorageResult<()> {
464        let mut conn = self
465            .conn
466            .lock()
467            .map_err(|_| StorageError::new("ndr_kv connection mutex poisoned"))?;
468        segmented::delete(
469            &mut conn,
470            (&self.owner_pubkey_hex, &self.device_pubkey_hex, key),
471        )
472    }
473
474    fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
475        let conn = self
476            .conn
477            .lock()
478            .map_err(|_| StorageError::new("ndr_kv connection mutex poisoned"))?;
479        let mut stmt = conn
480            .prepare(
481                "SELECT key FROM ndr_kv
482                 WHERE owner_pubkey_hex = ?1 AND device_pubkey_hex = ?2 AND key LIKE ?3 ESCAPE '\\'",
483            )
484            .map_err(Self::map_err)?;
485        let pattern = format!("{}%", escape_like(prefix));
486        let rows = stmt
487            .query_map(
488                (&self.owner_pubkey_hex, &self.device_pubkey_hex, &pattern),
489                |row| row.get::<_, String>(0),
490            )
491            .map_err(Self::map_err)?;
492        let mut keys = Vec::new();
493        for row in rows {
494            keys.push(row.map_err(Self::map_err)?);
495        }
496        Ok(keys)
497    }
498}
499
500fn escape_like(input: &str) -> String {
501    let mut out = String::with_capacity(input.len());
502    for ch in input.chars() {
503        match ch {
504            '\\' | '%' | '_' => {
505                out.push('\\');
506                out.push(ch);
507            }
508            other => out.push(other),
509        }
510    }
511    out
512}
513
514fn storage_io_error(context: &str, error: std::io::Error) -> StorageError {
515    StorageError::new(format!("{}: {}", context, error))
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use std::sync::{Arc, Mutex};
522    use tempfile::TempDir;
523
524    fn fresh_connection() -> SharedConnection {
525        let conn = rusqlite::Connection::open_in_memory().unwrap();
526        conn.execute_batch(
527            "CREATE TABLE ndr_kv (
528                owner_pubkey_hex TEXT NOT NULL,
529                device_pubkey_hex TEXT NOT NULL,
530                key TEXT NOT NULL,
531                value TEXT NOT NULL,
532                PRIMARY KEY (owner_pubkey_hex, device_pubkey_hex, key)
533            );",
534        )
535        .unwrap();
536        Arc::new(Mutex::new(conn))
537    }
538
539    fn fresh_adapter() -> SqliteStorageAdapter {
540        SqliteStorageAdapter::new(
541            fresh_connection(),
542            "owner".to_string(),
543            "device".to_string(),
544        )
545    }
546
547    #[test]
548    fn put_get_del_round_trip() {
549        let adapter = fresh_adapter();
550        assert!(adapter.get("k").unwrap().is_none());
551        adapter.put("k", "v".to_string()).unwrap();
552        assert_eq!(adapter.get("k").unwrap(), Some("v".to_string()));
553        adapter.put("k", "v2".to_string()).unwrap();
554        assert_eq!(adapter.get("k").unwrap(), Some("v2".to_string()));
555        adapter.del("k").unwrap();
556        assert!(adapter.get("k").unwrap().is_none());
557    }
558
559    #[test]
560    fn list_returns_only_matching_prefix() {
561        let adapter = fresh_adapter();
562        adapter.put("user/alice", "1".to_string()).unwrap();
563        adapter.put("user/bob", "2".to_string()).unwrap();
564        adapter.put("invite/charlie", "3".to_string()).unwrap();
565        let mut keys = adapter.list("user/").unwrap();
566        keys.sort();
567        assert_eq!(keys, vec!["user/alice".to_string(), "user/bob".to_string()]);
568    }
569
570    #[test]
571    fn keys_are_isolated_per_owner_device() {
572        let conn = fresh_connection();
573        let alice = SqliteStorageAdapter::new(conn.clone(), "owner_a".into(), "device_a".into());
574        let bob = SqliteStorageAdapter::new(conn, "owner_b".into(), "device_b".into());
575        alice.put("shared-key", "alice".to_string()).unwrap();
576        bob.put("shared-key", "bob".to_string()).unwrap();
577        assert_eq!(alice.get("shared-key").unwrap(), Some("alice".to_string()));
578        assert_eq!(bob.get("shared-key").unwrap(), Some("bob".to_string()));
579    }
580
581    #[test]
582    fn file_storage_round_trips_values() {
583        let temp_dir = TempDir::new().unwrap();
584        let adapter = FileStorageAdapter::new(temp_dir.path().to_path_buf()).unwrap();
585
586        assert!(adapter.get("test-key").unwrap().is_none());
587
588        adapter.put("test-key", "test-value".to_string()).unwrap();
589        assert_eq!(
590            adapter.get("test-key").unwrap(),
591            Some("test-value".to_string())
592        );
593
594        adapter.del("test-key").unwrap();
595        assert!(adapter.get("test-key").unwrap().is_none());
596    }
597
598    #[cfg(unix)]
599    #[test]
600    fn file_storage_keeps_session_secrets_private_when_created_and_replaced() {
601        use std::os::unix::fs::PermissionsExt;
602
603        let temp_dir = TempDir::new().unwrap();
604        let storage_dir = temp_dir.path().join("protocol");
605        let adapter = FileStorageAdapter::new(storage_dir.clone()).unwrap();
606        assert_eq!(
607            fs::metadata(&storage_dir).unwrap().permissions().mode() & 0o777,
608            0o700
609        );
610
611        adapter.put("session", "secret".to_string()).unwrap();
612        let session_path = storage_dir.join("session.json");
613        assert_eq!(
614            fs::metadata(&session_path).unwrap().permissions().mode() & 0o777,
615            0o600
616        );
617
618        // Upgrading an old file must not preserve its permissive mode.
619        fs::set_permissions(&session_path, fs::Permissions::from_mode(0o644)).unwrap();
620        adapter.put("session", "new secret".to_string()).unwrap();
621        assert_eq!(
622            fs::metadata(&session_path).unwrap().permissions().mode() & 0o777,
623            0o600
624        );
625        assert_eq!(
626            adapter.get("session").unwrap().as_deref(),
627            Some("new secret")
628        );
629    }
630
631    #[cfg(unix)]
632    #[test]
633    fn file_storage_protects_existing_session_directory() {
634        use std::os::unix::fs::PermissionsExt;
635
636        let temp_dir = TempDir::new().unwrap();
637        let storage_dir = temp_dir.path().join("protocol");
638        fs::create_dir(&storage_dir).unwrap();
639        fs::set_permissions(&storage_dir, fs::Permissions::from_mode(0o755)).unwrap();
640        fs::write(storage_dir.join("session.json"), "legacy secret").unwrap();
641
642        let adapter = FileStorageAdapter::new(storage_dir.clone()).unwrap();
643
644        assert_eq!(
645            fs::metadata(&storage_dir).unwrap().permissions().mode() & 0o777,
646            0o700
647        );
648        assert_eq!(
649            adapter.get("session").unwrap().as_deref(),
650            Some("legacy secret")
651        );
652    }
653
654    #[test]
655    fn file_storage_lists_sanitized_runtime_keys() {
656        let temp_dir = TempDir::new().unwrap();
657        let adapter = FileStorageAdapter::new(temp_dir.path().to_path_buf()).unwrap();
658
659        adapter.put("user/alice", "1".to_string()).unwrap();
660        adapter.put("user/bob", "2".to_string()).unwrap();
661        adapter.put("invite/charlie", "3".to_string()).unwrap();
662
663        let mut user_keys = adapter.list("user/").unwrap();
664        user_keys.sort();
665        assert_eq!(
666            user_keys,
667            vec!["user/alice".to_string(), "user/bob".to_string()]
668        );
669
670        let mut all_keys = adapter.list("").unwrap();
671        all_keys.sort();
672        assert_eq!(
673            all_keys,
674            vec![
675                "invite_charlie".to_string(),
676                "user_alice".to_string(),
677                "user_bob".to_string()
678            ]
679        );
680    }
681
682    #[test]
683    fn debounced_file_storage_reads_pending_writes_and_flushes() {
684        let temp_dir = TempDir::new().unwrap();
685        let storage = DebouncedFileStorage::new(temp_dir.path().to_path_buf(), 1000).unwrap();
686
687        storage.put("key1", "value1".to_string()).unwrap();
688
689        assert_eq!(storage.get("key1").unwrap(), Some("value1".to_string()));
690        assert!(storage.pending_writes.lock().unwrap().contains_key("key1"));
691
692        storage.flush().unwrap();
693
694        assert!(storage.pending_writes.lock().unwrap().is_empty());
695        assert_eq!(
696            storage.adapter.get("key1").unwrap(),
697            Some("value1".to_string())
698        );
699    }
700}