Skip to main content

rns_net/
storage.rs

1//! Identity, known destinations, and received ratchet persistence.
2//!
3//! Identity file format: 64 bytes = 32-byte X25519 private key + 32-byte Ed25519 private key.
4//! Same as Python's `Identity.to_file()` / `Identity.from_file()`.
5//!
6//! Known destinations: msgpack binary with 16-byte keys and tuple values.
7
8use 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/// Paths for storage directories.
19#[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    /// Directory for discovered interface data: storage/discovery/interfaces
27    pub discovered_interfaces: PathBuf,
28}
29
30/// A known destination entry.
31#[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
248/// Ensure all storage directories exist. Creates them if missing.
249pub 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
294/// Save an identity's private key to a file (64 bytes).
295pub 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
302/// Load an identity from a private key file (64 bytes).
303pub 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
316/// Save known destinations to a msgpack file.
317///
318/// Format: `{bytes(16): [received_at, public_key, app_data, identity_hash, hops,
319/// receiving_interface, was_used, last_used_at, retained], ...}`
320///
321/// Legacy 4-element arrays are still accepted on load.
322pub 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
386/// Load known destinations from a msgpack file.
387pub 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; // Skip invalid entries like Python does
411        }
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
492/// Resolve the config directory path.
493/// Priority: explicit path > `~/.reticulum/`
494pub 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
503/// Load or create an identity at the standard location.
504pub 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        // First 32 bytes: X25519 private key
558        // Next 32 bytes: Ed25519 private key (seed)
559        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        // Loading again should give same identity
789        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}