1use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use rns_crypto::identity::Identity;
15use rns_crypto::OsRng;
16
17#[derive(Debug, Clone)]
19pub struct StoragePaths {
20 pub config_dir: PathBuf,
21 pub storage: PathBuf,
22 pub cache: PathBuf,
23 pub identities: PathBuf,
24 pub ratchets: PathBuf,
25 pub discovered_interfaces: PathBuf,
27}
28
29#[derive(Debug, Clone)]
31pub struct KnownDestination {
32 pub identity_hash: [u8; 16],
33 pub public_key: [u8; 64],
34 pub app_data: Option<Vec<u8>>,
35 pub hops: u8,
36 pub received_at: f64,
37 pub receiving_interface: u64,
38 pub was_used: bool,
39 pub last_used_at: Option<f64>,
40 pub retained: bool,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct RatchetEntry {
45 pub ratchet: [u8; 32],
46 pub received_at: f64,
47}
48
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct RatchetCleanupStats {
51 pub processed: usize,
52 pub not_known: usize,
53 pub removed: usize,
54}
55
56pub trait RatchetStore: Send + Sync {
57 fn remember(&self, dest_hash: [u8; 16], entry: RatchetEntry) -> io::Result<()>;
58 fn current(
59 &self,
60 dest_hash: &[u8; 16],
61 now: f64,
62 expiry_secs: f64,
63 ) -> io::Result<Option<RatchetEntry>>;
64 fn cleanup(
65 &self,
66 known_destinations: &HashSet<[u8; 16]>,
67 now: f64,
68 expiry_secs: f64,
69 ) -> io::Result<RatchetCleanupStats>;
70}
71
72#[derive(Debug)]
73pub struct FsRatchetStore {
74 dir: PathBuf,
75 cache: Mutex<HashMap<[u8; 16], RatchetEntry>>,
76}
77
78impl FsRatchetStore {
79 pub fn new(dir: PathBuf) -> Self {
80 Self {
81 dir,
82 cache: Mutex::new(HashMap::new()),
83 }
84 }
85
86 fn path_for(&self, dest_hash: &[u8; 16]) -> PathBuf {
87 self.dir.join(hex_lower(dest_hash))
88 }
89
90 fn read_entry(path: &Path) -> io::Result<RatchetEntry> {
91 use rns_core::msgpack;
92
93 let data = fs::read(path)?;
94 let (value, _) = msgpack::unpack(&data).map_err(|e| {
95 io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e))
96 })?;
97 let ratchet = value
98 .map_get("ratchet")
99 .and_then(|v| v.as_bin())
100 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing ratchet"))?;
101 if ratchet.len() != 32 {
102 return Err(io::Error::new(
103 io::ErrorKind::InvalidData,
104 format!("ratchet must be 32 bytes, got {}", ratchet.len()),
105 ));
106 }
107 let mut ratchet_bytes = [0u8; 32];
108 ratchet_bytes.copy_from_slice(ratchet);
109 let received_at = value
110 .map_get("received")
111 .and_then(|v| v.as_number())
112 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing received"))?;
113
114 Ok(RatchetEntry {
115 ratchet: ratchet_bytes,
116 received_at,
117 })
118 }
119
120 fn write_entry(&self, path: &Path, entry: RatchetEntry) -> io::Result<()> {
121 use rns_core::msgpack::{self, Value};
122
123 fs::create_dir_all(&self.dir)?;
124 let value = Value::Map(vec![
125 (
126 Value::Str("ratchet".into()),
127 Value::Bin(entry.ratchet.to_vec()),
128 ),
129 (
130 Value::Str("received".into()),
131 Value::Float(entry.received_at),
132 ),
133 ]);
134 let packed = msgpack::pack(&value);
135 let tmp = path.with_extension("out");
136 fs::write(&tmp, packed)?;
137 fs::rename(tmp, path)
138 }
139}
140
141impl RatchetStore for FsRatchetStore {
142 fn remember(&self, dest_hash: [u8; 16], entry: RatchetEntry) -> io::Result<()> {
143 if self
144 .cache
145 .lock()
146 .map(|cache| cache.get(&dest_hash).copied() == Some(entry))
147 .unwrap_or(false)
148 {
149 return Ok(());
150 }
151
152 let path = self.path_for(&dest_hash);
153 self.write_entry(&path, entry)?;
154 if let Ok(mut cache) = self.cache.lock() {
155 cache.insert(dest_hash, entry);
156 }
157 Ok(())
158 }
159
160 fn current(
161 &self,
162 dest_hash: &[u8; 16],
163 now: f64,
164 expiry_secs: f64,
165 ) -> io::Result<Option<RatchetEntry>> {
166 if let Ok(cache) = self.cache.lock() {
167 if let Some(entry) = cache.get(dest_hash).copied() {
168 if now <= entry.received_at + expiry_secs {
169 return Ok(Some(entry));
170 }
171 }
172 }
173
174 let path = self.path_for(dest_hash);
175 if !path.is_file() {
176 return Ok(None);
177 }
178
179 let entry = Self::read_entry(&path)?;
180 if now > entry.received_at + expiry_secs {
181 let _ = fs::remove_file(path);
182 if let Ok(mut cache) = self.cache.lock() {
183 cache.remove(dest_hash);
184 }
185 return Ok(None);
186 }
187
188 if let Ok(mut cache) = self.cache.lock() {
189 cache.insert(*dest_hash, entry);
190 }
191 Ok(Some(entry))
192 }
193
194 fn cleanup(
195 &self,
196 known_destinations: &HashSet<[u8; 16]>,
197 now: f64,
198 expiry_secs: f64,
199 ) -> io::Result<RatchetCleanupStats> {
200 let mut stats = RatchetCleanupStats::default();
201 if !self.dir.is_dir() {
202 return Ok(stats);
203 }
204
205 for entry in fs::read_dir(&self.dir)? {
206 let entry = entry?;
207 if !entry.file_type()?.is_file() {
208 continue;
209 }
210 stats.processed += 1;
211 let path = entry.path();
212 let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
213 let _ = fs::remove_file(&path);
214 stats.removed += 1;
215 continue;
216 };
217
218 let Some(dest_hash) = parse_dest_hash_hex(filename) else {
219 let _ = fs::remove_file(&path);
220 stats.removed += 1;
221 continue;
222 };
223
224 let unknown = !known_destinations.contains(&dest_hash);
225 if unknown {
226 stats.not_known += 1;
227 }
228
229 let expired_or_corrupt = match Self::read_entry(&path) {
230 Ok(entry) => now > entry.received_at + expiry_secs,
231 Err(_) => true,
232 };
233
234 if unknown || expired_or_corrupt {
235 let _ = fs::remove_file(&path);
236 stats.removed += 1;
237 if let Ok(mut cache) = self.cache.lock() {
238 cache.remove(&dest_hash);
239 }
240 }
241 }
242
243 Ok(stats)
244 }
245}
246
247pub fn ensure_storage_dirs(config_dir: &Path) -> io::Result<StoragePaths> {
249 let storage = config_dir.join("storage");
250 let cache = config_dir.join("cache");
251 let identities = storage.join("identities");
252 let ratchets = storage.join("ratchets");
253 let announces = cache.join("announces");
254 let discovered_interfaces = storage.join("discovery").join("interfaces");
255
256 fs::create_dir_all(&storage)?;
257 fs::create_dir_all(&cache)?;
258 fs::create_dir_all(&identities)?;
259 fs::create_dir_all(&ratchets)?;
260 fs::create_dir_all(&announces)?;
261 fs::create_dir_all(&discovered_interfaces)?;
262
263 Ok(StoragePaths {
264 config_dir: config_dir.to_path_buf(),
265 storage,
266 cache,
267 identities,
268 ratchets,
269 discovered_interfaces,
270 })
271}
272
273fn hex_lower(bytes: &[u8]) -> String {
274 let mut out = String::with_capacity(bytes.len() * 2);
275 for byte in bytes {
276 use std::fmt::Write;
277 let _ = write!(&mut out, "{:02x}", byte);
278 }
279 out
280}
281
282fn parse_dest_hash_hex(s: &str) -> Option<[u8; 16]> {
283 if s.len() != 32 {
284 return None;
285 }
286 let mut out = [0u8; 16];
287 for i in 0..16 {
288 out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
289 }
290 Some(out)
291}
292
293pub fn save_identity(identity: &Identity, path: &Path) -> io::Result<()> {
295 let private_key = identity
296 .get_private_key()
297 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Identity has no private key"))?;
298 fs::write(path, &private_key)
299}
300
301pub fn load_identity(path: &Path) -> io::Result<Identity> {
303 let data = fs::read(path)?;
304 if data.len() != 64 {
305 return Err(io::Error::new(
306 io::ErrorKind::InvalidData,
307 format!("Identity file must be 64 bytes, got {}", data.len()),
308 ));
309 }
310 let mut key = [0u8; 64];
311 key.copy_from_slice(&data);
312 Ok(Identity::from_private_key(&key))
313}
314
315pub fn save_known_destinations(
322 destinations: &HashMap<[u8; 16], KnownDestination>,
323 path: &Path,
324) -> io::Result<()> {
325 use rns_core::msgpack::{self, Value};
326
327 let entries: Vec<(Value, Value)> = destinations
328 .iter()
329 .map(|(hash, dest)| {
330 let key = Value::Bin(hash.to_vec());
331 let app_data = match &dest.app_data {
332 Some(d) => Value::Bin(d.clone()),
333 None => Value::Nil,
334 };
335 let value = Value::Array(vec![
336 Value::UInt(dest.received_at as u64),
337 Value::Bin(dest.public_key.to_vec()),
338 app_data,
339 Value::Bin(dest.identity_hash.to_vec()),
340 Value::UInt(dest.hops as u64),
341 Value::UInt(dest.receiving_interface),
342 Value::Bool(dest.was_used),
343 match dest.last_used_at {
344 Some(last_used_at) => Value::UInt(last_used_at as u64),
345 None => Value::Nil,
346 },
347 Value::Bool(dest.retained),
348 ]);
349 (key, value)
350 })
351 .collect();
352
353 let packed = msgpack::pack(&Value::Map(entries));
354 fs::write(path, packed)
355}
356
357pub fn load_known_destinations(path: &Path) -> io::Result<HashMap<[u8; 16], KnownDestination>> {
359 use rns_core::msgpack;
360
361 let data = fs::read(path)?;
362 if data.is_empty() {
363 return Ok(HashMap::new());
364 }
365
366 let (value, _) = msgpack::unpack(&data)
367 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e)))?;
368
369 let map = value
370 .as_map()
371 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected msgpack map"))?;
372
373 let mut result = HashMap::new();
374
375 for (k, v) in map {
376 let hash_bytes = k
377 .as_bin()
378 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin key"))?;
379
380 if hash_bytes.len() != 16 {
381 continue; }
383
384 let mut dest_hash = [0u8; 16];
385 dest_hash.copy_from_slice(hash_bytes);
386
387 let arr = v
388 .as_array()
389 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected array value"))?;
390
391 if arr.len() < 3 {
392 continue;
393 }
394
395 let received_at = arr[0].as_uint().unwrap_or(0) as f64;
396
397 let pub_key_bytes = arr[1]
398 .as_bin()
399 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin public_key"))?;
400 if pub_key_bytes.len() != 64 {
401 continue;
402 }
403 let mut public_key = [0u8; 64];
404 public_key.copy_from_slice(pub_key_bytes);
405
406 let app_data = if arr.len() > 2 {
407 arr[2].as_bin().map(|b| b.to_vec())
408 } else {
409 None
410 };
411
412 let identity_hash = if arr.len() > 3 {
413 let hash_bytes = arr[3]
414 .as_bin()
415 .filter(|bytes| bytes.len() == 16)
416 .map(|bytes| {
417 let mut hash = [0u8; 16];
418 hash.copy_from_slice(bytes);
419 hash
420 });
421 hash_bytes.unwrap_or_else(|| {
422 let identity = Identity::from_public_key(&public_key);
423 *identity.hash()
424 })
425 } else {
426 let identity = Identity::from_public_key(&public_key);
427 *identity.hash()
428 };
429 let hops = arr.get(4).and_then(|value| value.as_uint()).unwrap_or(0) as u8;
430 let receiving_interface = arr.get(5).and_then(|value| value.as_uint()).unwrap_or(0);
431 let was_used = arr
432 .get(6)
433 .and_then(|value| value.as_bool())
434 .unwrap_or(false);
435 let last_used_at = arr
436 .get(7)
437 .and_then(|value| value.as_uint())
438 .map(|value| value as f64);
439 let retained = arr
440 .get(8)
441 .and_then(|value| value.as_bool())
442 .unwrap_or(false);
443
444 result.insert(
445 dest_hash,
446 KnownDestination {
447 identity_hash,
448 public_key,
449 app_data,
450 hops,
451 received_at,
452 receiving_interface,
453 was_used,
454 last_used_at,
455 retained,
456 },
457 );
458 }
459
460 Ok(result)
461}
462
463pub fn resolve_config_dir(explicit: Option<&Path>) -> PathBuf {
466 if let Some(p) = explicit {
467 p.to_path_buf()
468 } else {
469 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
470 PathBuf::from(home).join(".reticulum")
471 }
472}
473
474pub fn load_or_create_identity(identities_dir: &Path) -> io::Result<Identity> {
476 let id_path = identities_dir.join("identity");
477 if id_path.exists() {
478 load_identity(&id_path)
479 } else {
480 let identity = Identity::new(&mut OsRng);
481 save_identity(&identity, &id_path)?;
482 Ok(identity)
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 use std::sync::atomic::{AtomicU64, Ordering};
491 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
492
493 fn temp_dir() -> PathBuf {
494 let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
495 let dir = std::env::temp_dir().join(format!("rns-test-{}-{}", std::process::id(), id));
496 let _ = fs::remove_dir_all(&dir);
497 fs::create_dir_all(&dir).unwrap();
498 dir
499 }
500
501 #[test]
502 fn save_load_identity_roundtrip() {
503 let dir = temp_dir();
504 let path = dir.join("test_identity");
505
506 let identity = Identity::new(&mut OsRng);
507 let original_hash = *identity.hash();
508
509 save_identity(&identity, &path).unwrap();
510 let loaded = load_identity(&path).unwrap();
511
512 assert_eq!(*loaded.hash(), original_hash);
513
514 let _ = fs::remove_dir_all(&dir);
515 }
516
517 #[test]
518 fn identity_file_format() {
519 let dir = temp_dir();
520 let path = dir.join("test_identity_fmt");
521
522 let identity = Identity::new(&mut OsRng);
523 save_identity(&identity, &path).unwrap();
524
525 let data = fs::read(&path).unwrap();
526 assert_eq!(data.len(), 64, "Identity file must be exactly 64 bytes");
527
528 let private_key = identity.get_private_key();
531 let private_key = private_key.unwrap();
532 assert_eq!(&data[..], &private_key[..]);
533
534 let _ = fs::remove_dir_all(&dir);
535 }
536
537 #[test]
538 fn save_load_known_destinations_empty() {
539 let dir = temp_dir();
540 let path = dir.join("known_destinations");
541
542 let empty: HashMap<[u8; 16], KnownDestination> = HashMap::new();
543 save_known_destinations(&empty, &path).unwrap();
544
545 let loaded = load_known_destinations(&path).unwrap();
546 assert!(loaded.is_empty());
547
548 let _ = fs::remove_dir_all(&dir);
549 }
550
551 #[test]
552 fn save_load_known_destinations_roundtrip() {
553 let dir = temp_dir();
554 let path = dir.join("known_destinations");
555
556 let mut dests = HashMap::new();
557 dests.insert(
558 [0x01u8; 16],
559 KnownDestination {
560 identity_hash: [0x11u8; 16],
561 public_key: [0xABu8; 64],
562 app_data: Some(vec![0x01, 0x02, 0x03]),
563 hops: 2,
564 received_at: 1700000000.0,
565 receiving_interface: 7,
566 was_used: true,
567 last_used_at: Some(1700000010.0),
568 retained: true,
569 },
570 );
571 dests.insert(
572 [0x02u8; 16],
573 KnownDestination {
574 identity_hash: [0x22u8; 16],
575 public_key: [0xCDu8; 64],
576 app_data: None,
577 hops: 1,
578 received_at: 1700000001.0,
579 receiving_interface: 0,
580 was_used: false,
581 last_used_at: None,
582 retained: false,
583 },
584 );
585
586 save_known_destinations(&dests, &path).unwrap();
587 let loaded = load_known_destinations(&path).unwrap();
588
589 assert_eq!(loaded.len(), 2);
590
591 let d1 = &loaded[&[0x01u8; 16]];
592 assert_eq!(d1.identity_hash, [0x11u8; 16]);
593 assert_eq!(d1.public_key, [0xABu8; 64]);
594 assert_eq!(d1.app_data, Some(vec![0x01, 0x02, 0x03]));
595 assert_eq!(d1.hops, 2);
596 assert_eq!(d1.received_at as u64, 1700000000);
597 assert_eq!(d1.receiving_interface, 7);
598 assert!(d1.was_used);
599 assert_eq!(d1.last_used_at, Some(1700000010.0));
600 assert!(d1.retained);
601
602 let d2 = &loaded[&[0x02u8; 16]];
603 assert_eq!(d2.app_data, None);
604 assert!(!d2.was_used);
605 assert_eq!(d2.last_used_at, None);
606 assert!(!d2.retained);
607
608 let _ = fs::remove_dir_all(&dir);
609 }
610
611 #[test]
612 fn ratchet_store_roundtrip() {
613 let dir = temp_dir();
614 let store = FsRatchetStore::new(dir.join("ratchets"));
615 let dest = [0xAA; 16];
616 let entry = RatchetEntry {
617 ratchet: [0xBB; 32],
618 received_at: 1700000000.25,
619 };
620
621 store.remember(dest, entry).unwrap();
622 let loaded = store.current(&dest, 1700000001.0, 60.0).unwrap();
623
624 assert_eq!(loaded, Some(entry));
625 assert!(dir.join("ratchets").join(hex_lower(&dest)).exists());
626
627 let _ = fs::remove_dir_all(&dir);
628 }
629
630 #[test]
631 fn ratchet_cleanup_removes_expired_corrupt_unknown_and_temp() {
632 let dir = temp_dir();
633 let ratchets = dir.join("ratchets");
634 fs::create_dir_all(&ratchets).unwrap();
635 let store = FsRatchetStore::new(ratchets.clone());
636
637 let known_live = [0x01; 16];
638 let known_expired = [0x02; 16];
639 let unknown = [0x03; 16];
640 store
641 .remember(
642 known_live,
643 RatchetEntry {
644 ratchet: [0x11; 32],
645 received_at: 1000.0,
646 },
647 )
648 .unwrap();
649 store
650 .remember(
651 known_expired,
652 RatchetEntry {
653 ratchet: [0x22; 32],
654 received_at: 100.0,
655 },
656 )
657 .unwrap();
658 store
659 .remember(
660 unknown,
661 RatchetEntry {
662 ratchet: [0x33; 32],
663 received_at: 1000.0,
664 },
665 )
666 .unwrap();
667 fs::write(ratchets.join(hex_lower(&[0x04; 16])), b"not msgpack").unwrap();
668 fs::write(ratchets.join("0102.out"), b"temp").unwrap();
669
670 let known = HashSet::from([known_live, known_expired, [0x04; 16]]);
671 let stats = store.cleanup(&known, 1000.0, 300.0).unwrap();
672
673 assert_eq!(stats.processed, 5);
674 assert_eq!(stats.not_known, 1);
675 assert_eq!(stats.removed, 4);
676 assert!(ratchets.join(hex_lower(&known_live)).exists());
677 assert!(!ratchets.join(hex_lower(&known_expired)).exists());
678 assert!(!ratchets.join(hex_lower(&unknown)).exists());
679 assert!(!ratchets.join(hex_lower(&[0x04; 16])).exists());
680 assert!(!ratchets.join("0102.out").exists());
681
682 let _ = fs::remove_dir_all(&dir);
683 }
684
685 #[test]
686 fn ensure_dirs_creates() {
687 let dir = temp_dir().join("new_config");
688 let _ = fs::remove_dir_all(&dir);
689
690 let paths = ensure_storage_dirs(&dir).unwrap();
691
692 assert!(paths.storage.exists());
693 assert!(paths.cache.exists());
694 assert!(paths.identities.exists());
695 assert!(paths.ratchets.exists());
696 assert!(paths.discovered_interfaces.exists());
697
698 let _ = fs::remove_dir_all(&dir);
699 }
700
701 #[test]
702 fn ensure_dirs_existing() {
703 let dir = temp_dir().join("existing_config");
704 fs::create_dir_all(dir.join("storage")).unwrap();
705 fs::create_dir_all(dir.join("cache")).unwrap();
706
707 let paths = ensure_storage_dirs(&dir).unwrap();
708 assert!(paths.storage.exists());
709 assert!(paths.identities.exists());
710
711 let _ = fs::remove_dir_all(&dir);
712 }
713
714 #[test]
715 fn load_or_create_identity_new() {
716 let dir = temp_dir().join("load_or_create");
717 fs::create_dir_all(&dir).unwrap();
718
719 let identity = load_or_create_identity(&dir).unwrap();
720 let id_path = dir.join("identity");
721 assert!(id_path.exists());
722
723 let loaded = load_or_create_identity(&dir).unwrap();
725 assert_eq!(*identity.hash(), *loaded.hash());
726
727 let _ = fs::remove_dir_all(&dir);
728 }
729}