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