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 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 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 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 #[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 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 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
385pub 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 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 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}