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