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::{self, Write};
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    let mut options = fs::OpenOptions::new();
300    options.write(true).create(true).truncate(true);
301    #[cfg(unix)]
302    {
303        use std::os::unix::fs::OpenOptionsExt;
304        options.mode(0o600);
305    }
306    options.open(path)?.write_all(&private_key)
307}
308
309/// Load an identity from a private key file (64 bytes).
310pub fn load_identity(path: &Path) -> io::Result<Identity> {
311    let data = fs::read(path)?;
312    if data.len() != 64 {
313        return Err(io::Error::new(
314            io::ErrorKind::InvalidData,
315            format!("Identity file must be 64 bytes, got {}", data.len()),
316        ));
317    }
318    let mut key = [0u8; 64];
319    key.copy_from_slice(&data);
320    Ok(Identity::from_private_key(&key))
321}
322
323/// Save known destinations to a msgpack file.
324///
325/// Format: `{bytes(16): [received_at, public_key, app_data, identity_hash, hops,
326/// receiving_interface, was_used, last_used_at, retained], ...}`
327///
328/// Legacy 4-element arrays are still accepted on load.
329pub fn save_known_destinations(
330    destinations: &HashMap<[u8; 16], KnownDestination>,
331    path: &Path,
332) -> io::Result<()> {
333    use rns_core::msgpack::{self, Value};
334
335    let entries: Vec<(Value, Value)> = destinations
336        .iter()
337        .map(|(hash, dest)| {
338            let key = Value::Bin(hash.to_vec());
339            let app_data = match &dest.app_data {
340                Some(d) => Value::Bin(d.clone()),
341                None => Value::Nil,
342            };
343            let value = Value::Array(vec![
344                Value::UInt(dest.received_at as u64),
345                Value::Bin(dest.public_key.to_vec()),
346                app_data,
347                Value::Bin(dest.identity_hash.to_vec()),
348                Value::UInt(dest.hops as u64),
349                Value::UInt(dest.receiving_interface),
350                Value::Bool(dest.was_used),
351                match dest.last_used_at {
352                    Some(last_used_at) => Value::UInt(last_used_at as u64),
353                    None => Value::Nil,
354                },
355                Value::Bool(dest.retained),
356            ]);
357            (key, value)
358        })
359        .collect();
360
361    let packed = msgpack::pack(&Value::Map(entries));
362    atomic_write(path, &packed)
363}
364
365fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
366    let parent = path.parent().unwrap_or_else(|| Path::new("."));
367    let file_name = path
368        .file_name()
369        .and_then(|name| name.to_str())
370        .unwrap_or("known_destinations");
371    let nonce = SystemTime::now()
372        .duration_since(UNIX_EPOCH)
373        .map(|duration| duration.as_nanos())
374        .unwrap_or_default();
375    let temp_path = parent.join(format!(".{file_name}.tmp.{}.{}", std::process::id(), nonce));
376    match fs::write(&temp_path, data).and_then(|_| replace_file(&temp_path, path)) {
377        Ok(()) => Ok(()),
378        Err(err) => {
379            let _ = fs::remove_file(&temp_path);
380            Err(err)
381        }
382    }
383}
384
385fn replace_file(temp_path: &Path, path: &Path) -> io::Result<()> {
386    #[cfg(windows)]
387    if path.exists() {
388        fs::remove_file(path)?;
389    }
390    fs::rename(temp_path, path)
391}
392
393/// Load known destinations from a msgpack file.
394pub fn load_known_destinations(path: &Path) -> io::Result<HashMap<[u8; 16], KnownDestination>> {
395    use rns_core::msgpack;
396
397    let data = fs::read(path)?;
398    if data.is_empty() {
399        return Ok(HashMap::new());
400    }
401
402    let (value, _) = msgpack::unpack(&data)
403        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e)))?;
404
405    let map = value
406        .as_map()
407        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected msgpack map"))?;
408
409    let mut result = HashMap::new();
410
411    for (k, v) in map {
412        let hash_bytes = k
413            .as_bin()
414            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin key"))?;
415
416        if hash_bytes.len() != 16 {
417            continue; // Skip invalid entries like Python does
418        }
419
420        let mut dest_hash = [0u8; 16];
421        dest_hash.copy_from_slice(hash_bytes);
422
423        let arr = v
424            .as_array()
425            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected array value"))?;
426
427        if arr.len() < 3 {
428            continue;
429        }
430
431        let received_at = arr[0].as_uint().unwrap_or(0) as f64;
432
433        let pub_key_bytes = arr[1]
434            .as_bin()
435            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin public_key"))?;
436        if pub_key_bytes.len() != 64 {
437            continue;
438        }
439        let mut public_key = [0u8; 64];
440        public_key.copy_from_slice(pub_key_bytes);
441
442        let app_data = if arr.len() > 2 {
443            arr[2].as_bin().map(|b| b.to_vec())
444        } else {
445            None
446        };
447
448        let identity_hash = if arr.len() > 3 {
449            let hash_bytes = arr[3]
450                .as_bin()
451                .filter(|bytes| bytes.len() == 16)
452                .map(|bytes| {
453                    let mut hash = [0u8; 16];
454                    hash.copy_from_slice(bytes);
455                    hash
456                });
457            hash_bytes.unwrap_or_else(|| rns_core::hash::truncated_hash(&public_key))
458        } else {
459            rns_core::hash::truncated_hash(&public_key)
460        };
461        let hops = arr.get(4).and_then(|value| value.as_uint()).unwrap_or(0) as u8;
462        let receiving_interface = arr.get(5).and_then(|value| value.as_uint()).unwrap_or(0);
463        let was_used = arr
464            .get(6)
465            .and_then(|value| value.as_bool())
466            .unwrap_or(false);
467        let last_used_at = arr
468            .get(7)
469            .and_then(|value| value.as_uint())
470            .map(|value| value as f64);
471        let retained = arr
472            .get(8)
473            .and_then(|value| value.as_bool())
474            .unwrap_or(false);
475
476        result.insert(
477            dest_hash,
478            KnownDestination {
479                identity_hash,
480                public_key,
481                app_data,
482                hops,
483                received_at,
484                receiving_interface,
485                was_used,
486                last_used_at,
487                retained,
488            },
489        );
490    }
491
492    Ok(result)
493}
494
495/// Persist packet de-duplication, route, and tunnel state using the upstream
496/// Reticulum filenames and wire layouts.
497pub fn save_transport_state(
498    snapshot: &rns_core::transport::persistence::TransportStateSnapshot,
499    storage_dir: &Path,
500    low_priority: bool,
501) -> io::Result<()> {
502    let mut round_started = std::time::Instant::now();
503    save_transport_state_with_checkpoint(snapshot, storage_dir, || {
504        if low_priority && round_started.elapsed() > std::time::Duration::from_millis(10) {
505            std::thread::sleep(std::time::Duration::from_millis(1));
506            round_started = std::time::Instant::now();
507        }
508    })
509}
510
511fn save_transport_state_with_checkpoint<F>(
512    snapshot: &rns_core::transport::persistence::TransportStateSnapshot,
513    storage_dir: &Path,
514    mut checkpoint: F,
515) -> io::Result<()>
516where
517    F: FnMut(),
518{
519    use rns_core::msgpack::{self, Value};
520
521    fs::create_dir_all(storage_dir)?;
522
523    let mut hashes = Vec::with_capacity(snapshot.packet_hashes.len() * 32);
524    for hash in &snapshot.packet_hashes {
525        checkpoint();
526        hashes.extend_from_slice(hash);
527    }
528    atomic_write(&storage_dir.join("packet_hashlist.raw"), &hashes)?;
529
530    let paths = snapshot
531        .paths
532        .iter()
533        .map(|path| {
534            checkpoint();
535            encode_persisted_path(path)
536        })
537        .collect();
538    atomic_write(
539        &storage_dir.join("destination_table"),
540        &msgpack::pack(&Value::Array(paths)),
541    )?;
542
543    let tunnels = snapshot
544        .tunnels
545        .iter()
546        .map(|tunnel| {
547            checkpoint();
548            let paths = tunnel
549                .paths
550                .iter()
551                .map(|path| {
552                    checkpoint();
553                    encode_persisted_path(path)
554                })
555                .collect();
556            Value::Array(vec![
557                Value::Bin(tunnel.tunnel_id.to_vec()),
558                optional_hash_value(tunnel.interface_hash),
559                Value::Array(paths),
560                Value::Float(tunnel.expires),
561            ])
562        })
563        .collect();
564    atomic_write(
565        &storage_dir.join("tunnels"),
566        &msgpack::pack(&Value::Array(tunnels)),
567    )
568}
569
570fn encode_persisted_path(
571    path: &rns_core::transport::persistence::PersistedPath,
572) -> rns_core::msgpack::Value {
573    use rns_core::msgpack::Value;
574
575    Value::Array(vec![
576        Value::Bin(path.destination_hash.to_vec()),
577        Value::Float(path.timestamp),
578        Value::Bin(path.received_from.to_vec()),
579        Value::UInt(path.hops as u64),
580        Value::Float(path.expires),
581        Value::Array(
582            path.random_blobs
583                .iter()
584                .map(|blob| Value::Bin(blob.to_vec()))
585                .collect(),
586        ),
587        optional_hash_value(path.interface_hash),
588        Value::Bin(path.packet_hash.to_vec()),
589    ])
590}
591
592fn optional_hash_value(hash: Option<[u8; 32]>) -> rns_core::msgpack::Value {
593    match hash {
594        Some(hash) => rns_core::msgpack::Value::Bin(hash.to_vec()),
595        None => rns_core::msgpack::Value::Nil,
596    }
597}
598
599/// Load transport state. Missing files are treated as empty state; malformed
600/// existing files are surfaced so callers can log and continue deliberately.
601pub fn load_transport_state(
602    storage_dir: &Path,
603) -> io::Result<rns_core::transport::persistence::TransportStateSnapshot> {
604    use rns_core::transport::persistence::TransportStateSnapshot;
605
606    let packet_hashes = load_packet_hashlist(&storage_dir.join("packet_hashlist.raw"))?;
607    let paths = load_path_table(&storage_dir.join("destination_table"))?;
608    let tunnels = load_tunnel_table(&storage_dir.join("tunnels"))?;
609    Ok(TransportStateSnapshot {
610        packet_hashes,
611        paths,
612        tunnels,
613    })
614}
615
616fn load_packet_hashlist(path: &Path) -> io::Result<Vec<[u8; 32]>> {
617    let data = match fs::read(path) {
618        Ok(data) => data,
619        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
620        Err(err) => return Err(err),
621    };
622    Ok(data
623        .chunks_exact(32)
624        .map(|chunk| {
625            let mut hash = [0u8; 32];
626            hash.copy_from_slice(chunk);
627            hash
628        })
629        .collect())
630}
631
632fn load_path_table(
633    path: &Path,
634) -> io::Result<Vec<rns_core::transport::persistence::PersistedPath>> {
635    let Some(value) = load_optional_msgpack(path)? else {
636        return Ok(Vec::new());
637    };
638    value
639        .as_array()
640        .ok_or_else(|| invalid_transport_data("destination table must be an array"))?
641        .iter()
642        .map(decode_persisted_path)
643        .collect()
644}
645
646fn load_tunnel_table(
647    path: &Path,
648) -> io::Result<Vec<rns_core::transport::persistence::PersistedTunnel>> {
649    use rns_core::transport::persistence::PersistedTunnel;
650
651    let Some(value) = load_optional_msgpack(path)? else {
652        return Ok(Vec::new());
653    };
654    value
655        .as_array()
656        .ok_or_else(|| invalid_transport_data("tunnel table must be an array"))?
657        .iter()
658        .map(|value| {
659            let fields = value
660                .as_array()
661                .filter(|fields| fields.len() == 4)
662                .ok_or_else(|| invalid_transport_data("tunnel entry must contain 4 fields"))?;
663            let tunnel_id = fixed_bin::<32>(&fields[0], "tunnel id")?;
664            let interface_hash = optional_fixed_bin::<32>(&fields[1], "tunnel interface hash")?;
665            let paths = fields[2]
666                .as_array()
667                .ok_or_else(|| invalid_transport_data("tunnel paths must be an array"))?
668                .iter()
669                .map(decode_persisted_path)
670                .collect::<io::Result<Vec<_>>>()?;
671            let expires = fields[3]
672                .as_number()
673                .ok_or_else(|| invalid_transport_data("tunnel expiry must be numeric"))?;
674            Ok(PersistedTunnel {
675                tunnel_id,
676                interface_hash,
677                paths,
678                expires,
679            })
680        })
681        .collect()
682}
683
684fn decode_persisted_path(
685    value: &rns_core::msgpack::Value,
686) -> io::Result<rns_core::transport::persistence::PersistedPath> {
687    use rns_core::transport::persistence::PersistedPath;
688
689    let fields = value
690        .as_array()
691        .filter(|fields| fields.len() == 8)
692        .ok_or_else(|| invalid_transport_data("path entry must contain 8 fields"))?;
693    let random_blobs = fields[5]
694        .as_array()
695        .ok_or_else(|| invalid_transport_data("random blobs must be an array"))?
696        .iter()
697        .map(|value| fixed_bin::<10>(value, "random blob"))
698        .collect::<io::Result<Vec<_>>>()?;
699    let hops = fields[3]
700        .as_uint()
701        .and_then(|value| u8::try_from(value).ok())
702        .ok_or_else(|| invalid_transport_data("hop count must fit in a byte"))?;
703    Ok(PersistedPath {
704        destination_hash: fixed_bin::<16>(&fields[0], "destination hash")?,
705        timestamp: fields[1]
706            .as_number()
707            .ok_or_else(|| invalid_transport_data("path timestamp must be numeric"))?,
708        received_from: fixed_bin::<16>(&fields[2], "received-from hash")?,
709        hops,
710        expires: fields[4]
711            .as_number()
712            .ok_or_else(|| invalid_transport_data("path expiry must be numeric"))?,
713        random_blobs,
714        interface_hash: optional_fixed_bin::<32>(&fields[6], "interface hash")?,
715        packet_hash: fixed_bin::<32>(&fields[7], "packet hash")?,
716    })
717}
718
719fn load_optional_msgpack(path: &Path) -> io::Result<Option<rns_core::msgpack::Value>> {
720    let data = match fs::read(path) {
721        Ok(data) => data,
722        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
723        Err(err) => return Err(err),
724    };
725    rns_core::msgpack::unpack_exact(&data)
726        .map(Some)
727        .map_err(|err| invalid_transport_data(&format!("msgpack error: {err}")))
728}
729
730fn fixed_bin<const N: usize>(value: &rns_core::msgpack::Value, field: &str) -> io::Result<[u8; N]> {
731    let bytes = value
732        .as_bin()
733        .filter(|bytes| bytes.len() == N)
734        .ok_or_else(|| invalid_transport_data(&format!("{field} must be {N} bytes")))?;
735    let mut result = [0u8; N];
736    result.copy_from_slice(bytes);
737    Ok(result)
738}
739
740fn optional_fixed_bin<const N: usize>(
741    value: &rns_core::msgpack::Value,
742    field: &str,
743) -> io::Result<Option<[u8; N]>> {
744    if matches!(value, rns_core::msgpack::Value::Nil) {
745        Ok(None)
746    } else {
747        fixed_bin(value, field).map(Some)
748    }
749}
750
751fn invalid_transport_data(message: &str) -> io::Error {
752    io::Error::new(io::ErrorKind::InvalidData, message)
753}
754
755/// Resolve the config directory path.
756/// Priority: explicit path > `~/.reticulum/`
757pub fn resolve_config_dir(explicit: Option<&Path>) -> PathBuf {
758    if let Some(p) = explicit {
759        p.to_path_buf()
760    } else {
761        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
762        PathBuf::from(home).join(".reticulum")
763    }
764}
765
766/// Load or create an identity at the standard location.
767pub fn load_or_create_identity(identities_dir: &Path) -> io::Result<Identity> {
768    let id_path = identities_dir.join("identity");
769    if id_path.exists() {
770        load_identity(&id_path)
771    } else {
772        let identity = Identity::new(&mut OsRng);
773        save_identity(&identity, &id_path)?;
774        Ok(identity)
775    }
776}
777
778/// Path of the persisted transport identity (`storage/transport_identity`).
779///
780/// Matches Python's `Reticulum.storagepath + "/transport_identity"`.
781pub fn transport_identity_path(paths: &StoragePaths) -> PathBuf {
782    paths.storage.join("transport_identity")
783}
784
785/// Load the identity whose private key keys the shared-instance RPC socket.
786///
787/// The daemon (like Python's `Reticulum.rpc_key`) derives the RPC auth key
788/// from the persisted transport identity. A config dir the daemon has not yet
789/// initialized may lack it; the daemon then seeds it from the node identity,
790/// so fall back to `storage/identities/identity`.
791pub fn load_rpc_identity(paths: &StoragePaths) -> io::Result<Identity> {
792    let path = transport_identity_path(paths);
793    if path.exists() {
794        load_identity(&path)
795    } else {
796        load_or_create_identity(&paths.identities)
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    use std::sync::atomic::{AtomicU64, Ordering};
805    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
806
807    fn temp_dir() -> PathBuf {
808        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
809        let dir = std::env::temp_dir().join(format!("rns-test-{}-{}", std::process::id(), id));
810        let _ = fs::remove_dir_all(&dir);
811        fs::create_dir_all(&dir).unwrap();
812        dir
813    }
814
815    #[test]
816    fn save_load_identity_roundtrip() {
817        let dir = temp_dir();
818        let path = dir.join("test_identity");
819
820        let identity = Identity::new(&mut OsRng);
821        let original_hash = *identity.hash();
822
823        save_identity(&identity, &path).unwrap();
824        #[cfg(unix)]
825        {
826            use std::os::unix::fs::PermissionsExt;
827            assert_eq!(
828                fs::metadata(&path).unwrap().permissions().mode() & 0o777,
829                0o600
830            );
831        }
832        let loaded = load_identity(&path).unwrap();
833
834        assert_eq!(*loaded.hash(), original_hash);
835
836        let _ = fs::remove_dir_all(&dir);
837    }
838
839    #[test]
840    fn identity_file_format() {
841        let dir = temp_dir();
842        let path = dir.join("test_identity_fmt");
843
844        let identity = Identity::new(&mut OsRng);
845        save_identity(&identity, &path).unwrap();
846
847        let data = fs::read(&path).unwrap();
848        assert_eq!(data.len(), 64, "Identity file must be exactly 64 bytes");
849
850        // First 32 bytes: X25519 private key
851        // Next 32 bytes: Ed25519 private key (seed)
852        let private_key = identity.get_private_key();
853        let private_key = private_key.unwrap();
854        assert_eq!(&data[..], &private_key[..]);
855
856        let _ = fs::remove_dir_all(&dir);
857    }
858
859    #[test]
860    fn save_load_known_destinations_empty() {
861        let dir = temp_dir();
862        let path = dir.join("known_destinations");
863
864        let empty: HashMap<[u8; 16], KnownDestination> = HashMap::new();
865        save_known_destinations(&empty, &path).unwrap();
866
867        let loaded = load_known_destinations(&path).unwrap();
868        assert!(loaded.is_empty());
869
870        let _ = fs::remove_dir_all(&dir);
871    }
872
873    #[test]
874    fn save_load_known_destinations_roundtrip() {
875        let dir = temp_dir();
876        let path = dir.join("known_destinations");
877
878        let mut dests = HashMap::new();
879        dests.insert(
880            [0x01u8; 16],
881            KnownDestination {
882                identity_hash: [0x11u8; 16],
883                public_key: [0xABu8; 64],
884                app_data: Some(vec![0x01, 0x02, 0x03]),
885                hops: 2,
886                received_at: 1700000000.0,
887                receiving_interface: 7,
888                was_used: true,
889                last_used_at: Some(1700000010.0),
890                retained: true,
891            },
892        );
893        dests.insert(
894            [0x02u8; 16],
895            KnownDestination {
896                identity_hash: [0x22u8; 16],
897                public_key: [0xCDu8; 64],
898                app_data: None,
899                hops: 1,
900                received_at: 1700000001.0,
901                receiving_interface: 0,
902                was_used: false,
903                last_used_at: None,
904                retained: false,
905            },
906        );
907
908        save_known_destinations(&dests, &path).unwrap();
909        let loaded = load_known_destinations(&path).unwrap();
910
911        assert_eq!(loaded.len(), 2);
912
913        let d1 = &loaded[&[0x01u8; 16]];
914        assert_eq!(d1.identity_hash, [0x11u8; 16]);
915        assert_eq!(d1.public_key, [0xABu8; 64]);
916        assert_eq!(d1.app_data, Some(vec![0x01, 0x02, 0x03]));
917        assert_eq!(d1.hops, 2);
918        assert_eq!(d1.received_at as u64, 1700000000);
919        assert_eq!(d1.receiving_interface, 7);
920        assert!(d1.was_used);
921        assert_eq!(d1.last_used_at, Some(1700000010.0));
922        assert!(d1.retained);
923
924        let d2 = &loaded[&[0x02u8; 16]];
925        assert_eq!(d2.app_data, None);
926        assert!(!d2.was_used);
927        assert_eq!(d2.last_used_at, None);
928        assert!(!d2.retained);
929
930        let _ = fs::remove_dir_all(&dir);
931    }
932
933    #[test]
934    fn save_known_destinations_replaces_existing_file_atomically() {
935        let dir = temp_dir();
936        let path = dir.join("known_destinations");
937        fs::write(&path, b"old").unwrap();
938
939        let mut dests = HashMap::new();
940        dests.insert(
941            [0x03u8; 16],
942            KnownDestination {
943                identity_hash: [0x33u8; 16],
944                public_key: [0xEFu8; 64],
945                app_data: None,
946                hops: 3,
947                received_at: 1700000002.0,
948                receiving_interface: 9,
949                was_used: false,
950                last_used_at: None,
951                retained: false,
952            },
953        );
954
955        save_known_destinations(&dests, &path).unwrap();
956
957        let loaded = load_known_destinations(&path).unwrap();
958        assert_eq!(loaded.len(), 1);
959        assert!(loaded.contains_key(&[0x03u8; 16]));
960        let leftover_temp = fs::read_dir(&dir)
961            .unwrap()
962            .filter_map(Result::ok)
963            .any(|entry| entry.file_name().to_string_lossy().contains(".tmp."));
964        assert!(!leftover_temp);
965
966        let _ = fs::remove_dir_all(&dir);
967    }
968
969    #[test]
970    fn ratchet_store_roundtrip() {
971        let dir = temp_dir();
972        let store = FsRatchetStore::new(dir.join("ratchets"));
973        let dest = [0xAA; 16];
974        let entry = RatchetEntry {
975            ratchet: [0xBB; 32],
976            received_at: 1700000000.25,
977        };
978
979        store.remember(dest, entry).unwrap();
980        let loaded = store.current(&dest, 1700000001.0, 60.0).unwrap();
981
982        assert_eq!(loaded, Some(entry));
983        assert!(dir.join("ratchets").join(hex_lower(&dest)).exists());
984
985        let _ = fs::remove_dir_all(&dir);
986    }
987
988    #[test]
989    fn ratchet_cleanup_removes_expired_corrupt_unknown_and_temp() {
990        let dir = temp_dir();
991        let ratchets = dir.join("ratchets");
992        fs::create_dir_all(&ratchets).unwrap();
993        let store = FsRatchetStore::new(ratchets.clone());
994
995        let known_live = [0x01; 16];
996        let known_expired = [0x02; 16];
997        let unknown = [0x03; 16];
998        store
999            .remember(
1000                known_live,
1001                RatchetEntry {
1002                    ratchet: [0x11; 32],
1003                    received_at: 1000.0,
1004                },
1005            )
1006            .unwrap();
1007        store
1008            .remember(
1009                known_expired,
1010                RatchetEntry {
1011                    ratchet: [0x22; 32],
1012                    received_at: 100.0,
1013                },
1014            )
1015            .unwrap();
1016        store
1017            .remember(
1018                unknown,
1019                RatchetEntry {
1020                    ratchet: [0x33; 32],
1021                    received_at: 1000.0,
1022                },
1023            )
1024            .unwrap();
1025        fs::write(ratchets.join(hex_lower(&[0x04; 16])), b"not msgpack").unwrap();
1026        fs::write(ratchets.join("0102.out"), b"temp").unwrap();
1027
1028        let known = HashSet::from([known_live, known_expired, [0x04; 16]]);
1029        let stats = store.cleanup(&known, 1000.0, 300.0).unwrap();
1030
1031        assert_eq!(stats.processed, 5);
1032        assert_eq!(stats.not_known, 1);
1033        assert_eq!(stats.removed, 4);
1034        assert!(ratchets.join(hex_lower(&known_live)).exists());
1035        assert!(!ratchets.join(hex_lower(&known_expired)).exists());
1036        assert!(!ratchets.join(hex_lower(&unknown)).exists());
1037        assert!(!ratchets.join(hex_lower(&[0x04; 16])).exists());
1038        assert!(!ratchets.join("0102.out").exists());
1039
1040        let _ = fs::remove_dir_all(&dir);
1041    }
1042
1043    #[test]
1044    fn ensure_dirs_creates() {
1045        let dir = temp_dir().join("new_config");
1046        let _ = fs::remove_dir_all(&dir);
1047
1048        let paths = ensure_storage_dirs(&dir).unwrap();
1049
1050        assert!(paths.storage.exists());
1051        assert!(paths.cache.exists());
1052        assert!(paths.identities.exists());
1053        assert!(paths.ratchets.exists());
1054        assert!(paths.discovered_interfaces.exists());
1055
1056        let _ = fs::remove_dir_all(&dir);
1057    }
1058
1059    #[test]
1060    fn ensure_dirs_existing() {
1061        let dir = temp_dir().join("existing_config");
1062        fs::create_dir_all(dir.join("storage")).unwrap();
1063        fs::create_dir_all(dir.join("cache")).unwrap();
1064
1065        let paths = ensure_storage_dirs(&dir).unwrap();
1066        assert!(paths.storage.exists());
1067        assert!(paths.identities.exists());
1068
1069        let _ = fs::remove_dir_all(&dir);
1070    }
1071
1072    #[test]
1073    fn load_or_create_identity_new() {
1074        let dir = temp_dir().join("load_or_create");
1075        fs::create_dir_all(&dir).unwrap();
1076
1077        let identity = load_or_create_identity(&dir).unwrap();
1078        let id_path = dir.join("identity");
1079        assert!(id_path.exists());
1080
1081        // Loading again should give same identity
1082        let loaded = load_or_create_identity(&dir).unwrap();
1083        assert_eq!(*identity.hash(), *loaded.hash());
1084
1085        let _ = fs::remove_dir_all(&dir);
1086    }
1087
1088    fn transport_snapshot() -> rns_core::transport::persistence::TransportStateSnapshot {
1089        use rns_core::transport::persistence::{
1090            PersistedPath, PersistedTunnel, TransportStateSnapshot,
1091        };
1092
1093        let path = PersistedPath {
1094            destination_hash: [0x11; 16],
1095            timestamp: 10.25,
1096            received_from: [0x22; 16],
1097            hops: 3,
1098            expires: 20.5,
1099            random_blobs: vec![[0x33; 10], [0x44; 10]],
1100            interface_hash: Some([0x55; 32]),
1101            packet_hash: [0x66; 32],
1102        };
1103        TransportStateSnapshot {
1104            packet_hashes: vec![[1; 32], [2; 32]],
1105            paths: vec![path.clone()],
1106            tunnels: vec![PersistedTunnel {
1107                tunnel_id: [0x77; 32],
1108                interface_hash: None,
1109                paths: vec![PersistedPath {
1110                    destination_hash: [0x88; 16],
1111                    interface_hash: None,
1112                    ..path
1113                }],
1114                expires: 30.75,
1115            }],
1116        }
1117    }
1118
1119    #[test]
1120    fn packet_hashlist_raw_is_fixed_width_fifo_and_ignores_partial_tail() {
1121        let dir = temp_dir();
1122        let snapshot = transport_snapshot();
1123        save_transport_state(&snapshot, &dir, false).unwrap();
1124
1125        let path = dir.join("packet_hashlist.raw");
1126        let mut raw = fs::read(&path).unwrap();
1127        assert_eq!(raw.len(), 64);
1128        assert_eq!(&raw[..32], &[1; 32]);
1129        assert_eq!(&raw[32..], &[2; 32]);
1130        raw.extend_from_slice(&[0xFF; 17]);
1131        fs::write(&path, raw).unwrap();
1132
1133        assert_eq!(load_packet_hashlist(&path).unwrap(), vec![[1; 32], [2; 32]]);
1134        let _ = fs::remove_dir_all(&dir);
1135    }
1136
1137    #[test]
1138    fn path_and_tunnel_tables_round_trip_python_compatible_fields() {
1139        let dir = temp_dir();
1140        let expected = transport_snapshot();
1141        save_transport_state(&expected, &dir, false).unwrap();
1142        let loaded = load_transport_state(&dir).unwrap();
1143        assert_eq!(loaded, expected);
1144
1145        let packed = fs::read(dir.join("destination_table")).unwrap();
1146        let value = rns_core::msgpack::unpack_exact(&packed).unwrap();
1147        let entry = &value.as_array().unwrap()[0];
1148        assert_eq!(entry.as_array().unwrap().len(), 8);
1149        assert_eq!(entry.as_array().unwrap()[0].as_bin().unwrap().len(), 16);
1150        assert_eq!(entry.as_array().unwrap()[6].as_bin().unwrap().len(), 32);
1151
1152        let packed = fs::read(dir.join("tunnels")).unwrap();
1153        let value = rns_core::msgpack::unpack_exact(&packed).unwrap();
1154        let tunnel = &value.as_array().unwrap()[0];
1155        assert_eq!(tunnel.as_array().unwrap().len(), 4);
1156        assert!(matches!(
1157            tunnel.as_array().unwrap()[1],
1158            rns_core::msgpack::Value::Nil
1159        ));
1160        let _ = fs::remove_dir_all(&dir);
1161    }
1162
1163    #[test]
1164    fn missing_transport_state_files_load_as_empty_tables() {
1165        let dir = temp_dir();
1166        assert_eq!(
1167            load_transport_state(&dir).unwrap(),
1168            rns_core::transport::persistence::TransportStateSnapshot::default()
1169        );
1170        let _ = fs::remove_dir_all(&dir);
1171    }
1172
1173    #[test]
1174    fn low_priority_save_checkpoints_each_collection_without_temp_leaks() {
1175        let dir = temp_dir();
1176        let snapshot = transport_snapshot();
1177        let mut checkpoints = 0;
1178        save_transport_state_with_checkpoint(&snapshot, &dir, || checkpoints += 1).unwrap();
1179
1180        // 2 hashes + 1 route + 1 tunnel + 1 tunnel route.
1181        assert_eq!(checkpoints, 5);
1182        assert!(!fs::read_dir(&dir)
1183            .unwrap()
1184            .filter_map(Result::ok)
1185            .any(|entry| entry.file_name().to_string_lossy().contains(".tmp.")));
1186        let _ = fs::remove_dir_all(&dir);
1187    }
1188
1189    #[test]
1190    fn malformed_transport_table_reports_invalid_data_without_panicking() {
1191        let dir = temp_dir();
1192        fs::write(dir.join("destination_table"), b"not-msgpack").unwrap();
1193        let error = load_transport_state(&dir).unwrap_err();
1194        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1195        let _ = fs::remove_dir_all(&dir);
1196    }
1197
1198    #[test]
1199    fn load_rpc_identity_prefers_transport_identity() {
1200        let dir = temp_dir();
1201        let paths = ensure_storage_dirs(&dir).unwrap();
1202
1203        // Without transport_identity, fall back to identities/identity.
1204        let node = load_rpc_identity(&paths).unwrap();
1205        assert_eq!(
1206            *load_identity(&paths.identities.join("identity"))
1207                .unwrap()
1208                .hash(),
1209            *node.hash()
1210        );
1211
1212        // With a distinct transport_identity, it takes precedence.
1213        let transport = Identity::new(&mut OsRng);
1214        save_identity(&transport, &transport_identity_path(&paths)).unwrap();
1215        let rpc = load_rpc_identity(&paths).unwrap();
1216        assert_eq!(*rpc.hash(), *transport.hash());
1217        assert_ne!(*rpc.hash(), *node.hash());
1218
1219        let _ = fs::remove_dir_all(&dir);
1220    }
1221}