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/// Persist packet de-duplication, route, and tunnel state using the upstream
493/// Reticulum filenames and wire layouts.
494pub fn save_transport_state(
495    snapshot: &rns_core::transport::persistence::TransportStateSnapshot,
496    storage_dir: &Path,
497    low_priority: bool,
498) -> io::Result<()> {
499    let mut round_started = std::time::Instant::now();
500    save_transport_state_with_checkpoint(snapshot, storage_dir, || {
501        if low_priority && round_started.elapsed() > std::time::Duration::from_millis(10) {
502            std::thread::sleep(std::time::Duration::from_millis(1));
503            round_started = std::time::Instant::now();
504        }
505    })
506}
507
508fn save_transport_state_with_checkpoint<F>(
509    snapshot: &rns_core::transport::persistence::TransportStateSnapshot,
510    storage_dir: &Path,
511    mut checkpoint: F,
512) -> io::Result<()>
513where
514    F: FnMut(),
515{
516    use rns_core::msgpack::{self, Value};
517
518    fs::create_dir_all(storage_dir)?;
519
520    let mut hashes = Vec::with_capacity(snapshot.packet_hashes.len() * 32);
521    for hash in &snapshot.packet_hashes {
522        checkpoint();
523        hashes.extend_from_slice(hash);
524    }
525    atomic_write(&storage_dir.join("packet_hashlist.raw"), &hashes)?;
526
527    let paths = snapshot
528        .paths
529        .iter()
530        .map(|path| {
531            checkpoint();
532            encode_persisted_path(path)
533        })
534        .collect();
535    atomic_write(
536        &storage_dir.join("destination_table"),
537        &msgpack::pack(&Value::Array(paths)),
538    )?;
539
540    let tunnels = snapshot
541        .tunnels
542        .iter()
543        .map(|tunnel| {
544            checkpoint();
545            let paths = tunnel
546                .paths
547                .iter()
548                .map(|path| {
549                    checkpoint();
550                    encode_persisted_path(path)
551                })
552                .collect();
553            Value::Array(vec![
554                Value::Bin(tunnel.tunnel_id.to_vec()),
555                optional_hash_value(tunnel.interface_hash),
556                Value::Array(paths),
557                Value::Float(tunnel.expires),
558            ])
559        })
560        .collect();
561    atomic_write(
562        &storage_dir.join("tunnels"),
563        &msgpack::pack(&Value::Array(tunnels)),
564    )
565}
566
567fn encode_persisted_path(
568    path: &rns_core::transport::persistence::PersistedPath,
569) -> rns_core::msgpack::Value {
570    use rns_core::msgpack::Value;
571
572    Value::Array(vec![
573        Value::Bin(path.destination_hash.to_vec()),
574        Value::Float(path.timestamp),
575        Value::Bin(path.received_from.to_vec()),
576        Value::UInt(path.hops as u64),
577        Value::Float(path.expires),
578        Value::Array(
579            path.random_blobs
580                .iter()
581                .map(|blob| Value::Bin(blob.to_vec()))
582                .collect(),
583        ),
584        optional_hash_value(path.interface_hash),
585        Value::Bin(path.packet_hash.to_vec()),
586    ])
587}
588
589fn optional_hash_value(hash: Option<[u8; 32]>) -> rns_core::msgpack::Value {
590    match hash {
591        Some(hash) => rns_core::msgpack::Value::Bin(hash.to_vec()),
592        None => rns_core::msgpack::Value::Nil,
593    }
594}
595
596/// Load transport state. Missing files are treated as empty state; malformed
597/// existing files are surfaced so callers can log and continue deliberately.
598pub fn load_transport_state(
599    storage_dir: &Path,
600) -> io::Result<rns_core::transport::persistence::TransportStateSnapshot> {
601    use rns_core::transport::persistence::TransportStateSnapshot;
602
603    let packet_hashes = load_packet_hashlist(&storage_dir.join("packet_hashlist.raw"))?;
604    let paths = load_path_table(&storage_dir.join("destination_table"))?;
605    let tunnels = load_tunnel_table(&storage_dir.join("tunnels"))?;
606    Ok(TransportStateSnapshot {
607        packet_hashes,
608        paths,
609        tunnels,
610    })
611}
612
613fn load_packet_hashlist(path: &Path) -> io::Result<Vec<[u8; 32]>> {
614    let data = match fs::read(path) {
615        Ok(data) => data,
616        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
617        Err(err) => return Err(err),
618    };
619    Ok(data
620        .chunks_exact(32)
621        .map(|chunk| {
622            let mut hash = [0u8; 32];
623            hash.copy_from_slice(chunk);
624            hash
625        })
626        .collect())
627}
628
629fn load_path_table(
630    path: &Path,
631) -> io::Result<Vec<rns_core::transport::persistence::PersistedPath>> {
632    let Some(value) = load_optional_msgpack(path)? else {
633        return Ok(Vec::new());
634    };
635    value
636        .as_array()
637        .ok_or_else(|| invalid_transport_data("destination table must be an array"))?
638        .iter()
639        .map(decode_persisted_path)
640        .collect()
641}
642
643fn load_tunnel_table(
644    path: &Path,
645) -> io::Result<Vec<rns_core::transport::persistence::PersistedTunnel>> {
646    use rns_core::transport::persistence::PersistedTunnel;
647
648    let Some(value) = load_optional_msgpack(path)? else {
649        return Ok(Vec::new());
650    };
651    value
652        .as_array()
653        .ok_or_else(|| invalid_transport_data("tunnel table must be an array"))?
654        .iter()
655        .map(|value| {
656            let fields = value
657                .as_array()
658                .filter(|fields| fields.len() == 4)
659                .ok_or_else(|| invalid_transport_data("tunnel entry must contain 4 fields"))?;
660            let tunnel_id = fixed_bin::<32>(&fields[0], "tunnel id")?;
661            let interface_hash = optional_fixed_bin::<32>(&fields[1], "tunnel interface hash")?;
662            let paths = fields[2]
663                .as_array()
664                .ok_or_else(|| invalid_transport_data("tunnel paths must be an array"))?
665                .iter()
666                .map(decode_persisted_path)
667                .collect::<io::Result<Vec<_>>>()?;
668            let expires = fields[3]
669                .as_number()
670                .ok_or_else(|| invalid_transport_data("tunnel expiry must be numeric"))?;
671            Ok(PersistedTunnel {
672                tunnel_id,
673                interface_hash,
674                paths,
675                expires,
676            })
677        })
678        .collect()
679}
680
681fn decode_persisted_path(
682    value: &rns_core::msgpack::Value,
683) -> io::Result<rns_core::transport::persistence::PersistedPath> {
684    use rns_core::transport::persistence::PersistedPath;
685
686    let fields = value
687        .as_array()
688        .filter(|fields| fields.len() == 8)
689        .ok_or_else(|| invalid_transport_data("path entry must contain 8 fields"))?;
690    let random_blobs = fields[5]
691        .as_array()
692        .ok_or_else(|| invalid_transport_data("random blobs must be an array"))?
693        .iter()
694        .map(|value| fixed_bin::<10>(value, "random blob"))
695        .collect::<io::Result<Vec<_>>>()?;
696    let hops = fields[3]
697        .as_uint()
698        .and_then(|value| u8::try_from(value).ok())
699        .ok_or_else(|| invalid_transport_data("hop count must fit in a byte"))?;
700    Ok(PersistedPath {
701        destination_hash: fixed_bin::<16>(&fields[0], "destination hash")?,
702        timestamp: fields[1]
703            .as_number()
704            .ok_or_else(|| invalid_transport_data("path timestamp must be numeric"))?,
705        received_from: fixed_bin::<16>(&fields[2], "received-from hash")?,
706        hops,
707        expires: fields[4]
708            .as_number()
709            .ok_or_else(|| invalid_transport_data("path expiry must be numeric"))?,
710        random_blobs,
711        interface_hash: optional_fixed_bin::<32>(&fields[6], "interface hash")?,
712        packet_hash: fixed_bin::<32>(&fields[7], "packet hash")?,
713    })
714}
715
716fn load_optional_msgpack(path: &Path) -> io::Result<Option<rns_core::msgpack::Value>> {
717    let data = match fs::read(path) {
718        Ok(data) => data,
719        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
720        Err(err) => return Err(err),
721    };
722    rns_core::msgpack::unpack_exact(&data)
723        .map(Some)
724        .map_err(|err| invalid_transport_data(&format!("msgpack error: {err}")))
725}
726
727fn fixed_bin<const N: usize>(value: &rns_core::msgpack::Value, field: &str) -> io::Result<[u8; N]> {
728    let bytes = value
729        .as_bin()
730        .filter(|bytes| bytes.len() == N)
731        .ok_or_else(|| invalid_transport_data(&format!("{field} must be {N} bytes")))?;
732    let mut result = [0u8; N];
733    result.copy_from_slice(bytes);
734    Ok(result)
735}
736
737fn optional_fixed_bin<const N: usize>(
738    value: &rns_core::msgpack::Value,
739    field: &str,
740) -> io::Result<Option<[u8; N]>> {
741    if matches!(value, rns_core::msgpack::Value::Nil) {
742        Ok(None)
743    } else {
744        fixed_bin(value, field).map(Some)
745    }
746}
747
748fn invalid_transport_data(message: &str) -> io::Error {
749    io::Error::new(io::ErrorKind::InvalidData, message)
750}
751
752/// Resolve the config directory path.
753/// Priority: explicit path > `~/.reticulum/`
754pub fn resolve_config_dir(explicit: Option<&Path>) -> PathBuf {
755    if let Some(p) = explicit {
756        p.to_path_buf()
757    } else {
758        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
759        PathBuf::from(home).join(".reticulum")
760    }
761}
762
763/// Load or create an identity at the standard location.
764pub fn load_or_create_identity(identities_dir: &Path) -> io::Result<Identity> {
765    let id_path = identities_dir.join("identity");
766    if id_path.exists() {
767        load_identity(&id_path)
768    } else {
769        let identity = Identity::new(&mut OsRng);
770        save_identity(&identity, &id_path)?;
771        Ok(identity)
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778
779    use std::sync::atomic::{AtomicU64, Ordering};
780    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
781
782    fn temp_dir() -> PathBuf {
783        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
784        let dir = std::env::temp_dir().join(format!("rns-test-{}-{}", std::process::id(), id));
785        let _ = fs::remove_dir_all(&dir);
786        fs::create_dir_all(&dir).unwrap();
787        dir
788    }
789
790    #[test]
791    fn save_load_identity_roundtrip() {
792        let dir = temp_dir();
793        let path = dir.join("test_identity");
794
795        let identity = Identity::new(&mut OsRng);
796        let original_hash = *identity.hash();
797
798        save_identity(&identity, &path).unwrap();
799        let loaded = load_identity(&path).unwrap();
800
801        assert_eq!(*loaded.hash(), original_hash);
802
803        let _ = fs::remove_dir_all(&dir);
804    }
805
806    #[test]
807    fn identity_file_format() {
808        let dir = temp_dir();
809        let path = dir.join("test_identity_fmt");
810
811        let identity = Identity::new(&mut OsRng);
812        save_identity(&identity, &path).unwrap();
813
814        let data = fs::read(&path).unwrap();
815        assert_eq!(data.len(), 64, "Identity file must be exactly 64 bytes");
816
817        // First 32 bytes: X25519 private key
818        // Next 32 bytes: Ed25519 private key (seed)
819        let private_key = identity.get_private_key();
820        let private_key = private_key.unwrap();
821        assert_eq!(&data[..], &private_key[..]);
822
823        let _ = fs::remove_dir_all(&dir);
824    }
825
826    #[test]
827    fn save_load_known_destinations_empty() {
828        let dir = temp_dir();
829        let path = dir.join("known_destinations");
830
831        let empty: HashMap<[u8; 16], KnownDestination> = HashMap::new();
832        save_known_destinations(&empty, &path).unwrap();
833
834        let loaded = load_known_destinations(&path).unwrap();
835        assert!(loaded.is_empty());
836
837        let _ = fs::remove_dir_all(&dir);
838    }
839
840    #[test]
841    fn save_load_known_destinations_roundtrip() {
842        let dir = temp_dir();
843        let path = dir.join("known_destinations");
844
845        let mut dests = HashMap::new();
846        dests.insert(
847            [0x01u8; 16],
848            KnownDestination {
849                identity_hash: [0x11u8; 16],
850                public_key: [0xABu8; 64],
851                app_data: Some(vec![0x01, 0x02, 0x03]),
852                hops: 2,
853                received_at: 1700000000.0,
854                receiving_interface: 7,
855                was_used: true,
856                last_used_at: Some(1700000010.0),
857                retained: true,
858            },
859        );
860        dests.insert(
861            [0x02u8; 16],
862            KnownDestination {
863                identity_hash: [0x22u8; 16],
864                public_key: [0xCDu8; 64],
865                app_data: None,
866                hops: 1,
867                received_at: 1700000001.0,
868                receiving_interface: 0,
869                was_used: false,
870                last_used_at: None,
871                retained: false,
872            },
873        );
874
875        save_known_destinations(&dests, &path).unwrap();
876        let loaded = load_known_destinations(&path).unwrap();
877
878        assert_eq!(loaded.len(), 2);
879
880        let d1 = &loaded[&[0x01u8; 16]];
881        assert_eq!(d1.identity_hash, [0x11u8; 16]);
882        assert_eq!(d1.public_key, [0xABu8; 64]);
883        assert_eq!(d1.app_data, Some(vec![0x01, 0x02, 0x03]));
884        assert_eq!(d1.hops, 2);
885        assert_eq!(d1.received_at as u64, 1700000000);
886        assert_eq!(d1.receiving_interface, 7);
887        assert!(d1.was_used);
888        assert_eq!(d1.last_used_at, Some(1700000010.0));
889        assert!(d1.retained);
890
891        let d2 = &loaded[&[0x02u8; 16]];
892        assert_eq!(d2.app_data, None);
893        assert!(!d2.was_used);
894        assert_eq!(d2.last_used_at, None);
895        assert!(!d2.retained);
896
897        let _ = fs::remove_dir_all(&dir);
898    }
899
900    #[test]
901    fn save_known_destinations_replaces_existing_file_atomically() {
902        let dir = temp_dir();
903        let path = dir.join("known_destinations");
904        fs::write(&path, b"old").unwrap();
905
906        let mut dests = HashMap::new();
907        dests.insert(
908            [0x03u8; 16],
909            KnownDestination {
910                identity_hash: [0x33u8; 16],
911                public_key: [0xEFu8; 64],
912                app_data: None,
913                hops: 3,
914                received_at: 1700000002.0,
915                receiving_interface: 9,
916                was_used: false,
917                last_used_at: None,
918                retained: false,
919            },
920        );
921
922        save_known_destinations(&dests, &path).unwrap();
923
924        let loaded = load_known_destinations(&path).unwrap();
925        assert_eq!(loaded.len(), 1);
926        assert!(loaded.contains_key(&[0x03u8; 16]));
927        let leftover_temp = fs::read_dir(&dir)
928            .unwrap()
929            .filter_map(Result::ok)
930            .any(|entry| entry.file_name().to_string_lossy().contains(".tmp."));
931        assert!(!leftover_temp);
932
933        let _ = fs::remove_dir_all(&dir);
934    }
935
936    #[test]
937    fn ratchet_store_roundtrip() {
938        let dir = temp_dir();
939        let store = FsRatchetStore::new(dir.join("ratchets"));
940        let dest = [0xAA; 16];
941        let entry = RatchetEntry {
942            ratchet: [0xBB; 32],
943            received_at: 1700000000.25,
944        };
945
946        store.remember(dest, entry).unwrap();
947        let loaded = store.current(&dest, 1700000001.0, 60.0).unwrap();
948
949        assert_eq!(loaded, Some(entry));
950        assert!(dir.join("ratchets").join(hex_lower(&dest)).exists());
951
952        let _ = fs::remove_dir_all(&dir);
953    }
954
955    #[test]
956    fn ratchet_cleanup_removes_expired_corrupt_unknown_and_temp() {
957        let dir = temp_dir();
958        let ratchets = dir.join("ratchets");
959        fs::create_dir_all(&ratchets).unwrap();
960        let store = FsRatchetStore::new(ratchets.clone());
961
962        let known_live = [0x01; 16];
963        let known_expired = [0x02; 16];
964        let unknown = [0x03; 16];
965        store
966            .remember(
967                known_live,
968                RatchetEntry {
969                    ratchet: [0x11; 32],
970                    received_at: 1000.0,
971                },
972            )
973            .unwrap();
974        store
975            .remember(
976                known_expired,
977                RatchetEntry {
978                    ratchet: [0x22; 32],
979                    received_at: 100.0,
980                },
981            )
982            .unwrap();
983        store
984            .remember(
985                unknown,
986                RatchetEntry {
987                    ratchet: [0x33; 32],
988                    received_at: 1000.0,
989                },
990            )
991            .unwrap();
992        fs::write(ratchets.join(hex_lower(&[0x04; 16])), b"not msgpack").unwrap();
993        fs::write(ratchets.join("0102.out"), b"temp").unwrap();
994
995        let known = HashSet::from([known_live, known_expired, [0x04; 16]]);
996        let stats = store.cleanup(&known, 1000.0, 300.0).unwrap();
997
998        assert_eq!(stats.processed, 5);
999        assert_eq!(stats.not_known, 1);
1000        assert_eq!(stats.removed, 4);
1001        assert!(ratchets.join(hex_lower(&known_live)).exists());
1002        assert!(!ratchets.join(hex_lower(&known_expired)).exists());
1003        assert!(!ratchets.join(hex_lower(&unknown)).exists());
1004        assert!(!ratchets.join(hex_lower(&[0x04; 16])).exists());
1005        assert!(!ratchets.join("0102.out").exists());
1006
1007        let _ = fs::remove_dir_all(&dir);
1008    }
1009
1010    #[test]
1011    fn ensure_dirs_creates() {
1012        let dir = temp_dir().join("new_config");
1013        let _ = fs::remove_dir_all(&dir);
1014
1015        let paths = ensure_storage_dirs(&dir).unwrap();
1016
1017        assert!(paths.storage.exists());
1018        assert!(paths.cache.exists());
1019        assert!(paths.identities.exists());
1020        assert!(paths.ratchets.exists());
1021        assert!(paths.discovered_interfaces.exists());
1022
1023        let _ = fs::remove_dir_all(&dir);
1024    }
1025
1026    #[test]
1027    fn ensure_dirs_existing() {
1028        let dir = temp_dir().join("existing_config");
1029        fs::create_dir_all(dir.join("storage")).unwrap();
1030        fs::create_dir_all(dir.join("cache")).unwrap();
1031
1032        let paths = ensure_storage_dirs(&dir).unwrap();
1033        assert!(paths.storage.exists());
1034        assert!(paths.identities.exists());
1035
1036        let _ = fs::remove_dir_all(&dir);
1037    }
1038
1039    #[test]
1040    fn load_or_create_identity_new() {
1041        let dir = temp_dir().join("load_or_create");
1042        fs::create_dir_all(&dir).unwrap();
1043
1044        let identity = load_or_create_identity(&dir).unwrap();
1045        let id_path = dir.join("identity");
1046        assert!(id_path.exists());
1047
1048        // Loading again should give same identity
1049        let loaded = load_or_create_identity(&dir).unwrap();
1050        assert_eq!(*identity.hash(), *loaded.hash());
1051
1052        let _ = fs::remove_dir_all(&dir);
1053    }
1054
1055    fn transport_snapshot() -> rns_core::transport::persistence::TransportStateSnapshot {
1056        use rns_core::transport::persistence::{
1057            PersistedPath, PersistedTunnel, TransportStateSnapshot,
1058        };
1059
1060        let path = PersistedPath {
1061            destination_hash: [0x11; 16],
1062            timestamp: 10.25,
1063            received_from: [0x22; 16],
1064            hops: 3,
1065            expires: 20.5,
1066            random_blobs: vec![[0x33; 10], [0x44; 10]],
1067            interface_hash: Some([0x55; 32]),
1068            packet_hash: [0x66; 32],
1069        };
1070        TransportStateSnapshot {
1071            packet_hashes: vec![[1; 32], [2; 32]],
1072            paths: vec![path.clone()],
1073            tunnels: vec![PersistedTunnel {
1074                tunnel_id: [0x77; 32],
1075                interface_hash: None,
1076                paths: vec![PersistedPath {
1077                    destination_hash: [0x88; 16],
1078                    interface_hash: None,
1079                    ..path
1080                }],
1081                expires: 30.75,
1082            }],
1083        }
1084    }
1085
1086    #[test]
1087    fn packet_hashlist_raw_is_fixed_width_fifo_and_ignores_partial_tail() {
1088        let dir = temp_dir();
1089        let snapshot = transport_snapshot();
1090        save_transport_state(&snapshot, &dir, false).unwrap();
1091
1092        let path = dir.join("packet_hashlist.raw");
1093        let mut raw = fs::read(&path).unwrap();
1094        assert_eq!(raw.len(), 64);
1095        assert_eq!(&raw[..32], &[1; 32]);
1096        assert_eq!(&raw[32..], &[2; 32]);
1097        raw.extend_from_slice(&[0xFF; 17]);
1098        fs::write(&path, raw).unwrap();
1099
1100        assert_eq!(load_packet_hashlist(&path).unwrap(), vec![[1; 32], [2; 32]]);
1101        let _ = fs::remove_dir_all(&dir);
1102    }
1103
1104    #[test]
1105    fn path_and_tunnel_tables_round_trip_python_compatible_fields() {
1106        let dir = temp_dir();
1107        let expected = transport_snapshot();
1108        save_transport_state(&expected, &dir, false).unwrap();
1109        let loaded = load_transport_state(&dir).unwrap();
1110        assert_eq!(loaded, expected);
1111
1112        let packed = fs::read(dir.join("destination_table")).unwrap();
1113        let value = rns_core::msgpack::unpack_exact(&packed).unwrap();
1114        let entry = &value.as_array().unwrap()[0];
1115        assert_eq!(entry.as_array().unwrap().len(), 8);
1116        assert_eq!(entry.as_array().unwrap()[0].as_bin().unwrap().len(), 16);
1117        assert_eq!(entry.as_array().unwrap()[6].as_bin().unwrap().len(), 32);
1118
1119        let packed = fs::read(dir.join("tunnels")).unwrap();
1120        let value = rns_core::msgpack::unpack_exact(&packed).unwrap();
1121        let tunnel = &value.as_array().unwrap()[0];
1122        assert_eq!(tunnel.as_array().unwrap().len(), 4);
1123        assert!(matches!(
1124            tunnel.as_array().unwrap()[1],
1125            rns_core::msgpack::Value::Nil
1126        ));
1127        let _ = fs::remove_dir_all(&dir);
1128    }
1129
1130    #[test]
1131    fn missing_transport_state_files_load_as_empty_tables() {
1132        let dir = temp_dir();
1133        assert_eq!(
1134            load_transport_state(&dir).unwrap(),
1135            rns_core::transport::persistence::TransportStateSnapshot::default()
1136        );
1137        let _ = fs::remove_dir_all(&dir);
1138    }
1139
1140    #[test]
1141    fn low_priority_save_checkpoints_each_collection_without_temp_leaks() {
1142        let dir = temp_dir();
1143        let snapshot = transport_snapshot();
1144        let mut checkpoints = 0;
1145        save_transport_state_with_checkpoint(&snapshot, &dir, || checkpoints += 1).unwrap();
1146
1147        // 2 hashes + 1 route + 1 tunnel + 1 tunnel route.
1148        assert_eq!(checkpoints, 5);
1149        assert!(!fs::read_dir(&dir)
1150            .unwrap()
1151            .filter_map(Result::ok)
1152            .any(|entry| entry.file_name().to_string_lossy().contains(".tmp.")));
1153        let _ = fs::remove_dir_all(&dir);
1154    }
1155
1156    #[test]
1157    fn malformed_transport_table_reports_invalid_data_without_panicking() {
1158        let dir = temp_dir();
1159        fs::write(dir.join("destination_table"), b"not-msgpack").unwrap();
1160        let error = load_transport_state(&dir).unwrap_err();
1161        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1162        let _ = fs::remove_dir_all(&dir);
1163    }
1164}