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