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