Skip to main content

rns_net/
storage.rs

1//! Identity and known destinations 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;
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use rns_crypto::identity::Identity;
14use rns_crypto::OsRng;
15
16/// Paths for storage directories.
17#[derive(Debug, Clone)]
18pub struct StoragePaths {
19    pub config_dir: PathBuf,
20    pub storage: PathBuf,
21    pub cache: PathBuf,
22    pub identities: PathBuf,
23    /// Directory for discovered interface data: storage/discovery/interfaces
24    pub discovered_interfaces: PathBuf,
25}
26
27/// A known destination entry.
28#[derive(Debug, Clone)]
29pub struct KnownDestination {
30    pub identity_hash: [u8; 16],
31    pub public_key: [u8; 64],
32    pub app_data: Option<Vec<u8>>,
33    pub hops: u8,
34    pub received_at: f64,
35    pub receiving_interface: u64,
36    pub was_used: bool,
37    pub last_used_at: Option<f64>,
38    pub retained: bool,
39}
40
41/// Ensure all storage directories exist. Creates them if missing.
42pub fn ensure_storage_dirs(config_dir: &Path) -> io::Result<StoragePaths> {
43    let storage = config_dir.join("storage");
44    let cache = config_dir.join("cache");
45    let identities = storage.join("identities");
46    let announces = cache.join("announces");
47    let discovered_interfaces = storage.join("discovery").join("interfaces");
48
49    fs::create_dir_all(&storage)?;
50    fs::create_dir_all(&cache)?;
51    fs::create_dir_all(&identities)?;
52    fs::create_dir_all(&announces)?;
53    fs::create_dir_all(&discovered_interfaces)?;
54
55    Ok(StoragePaths {
56        config_dir: config_dir.to_path_buf(),
57        storage,
58        cache,
59        identities,
60        discovered_interfaces,
61    })
62}
63
64/// Save an identity's private key to a file (64 bytes).
65pub fn save_identity(identity: &Identity, path: &Path) -> io::Result<()> {
66    let private_key = identity
67        .get_private_key()
68        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Identity has no private key"))?;
69    fs::write(path, &private_key)
70}
71
72/// Load an identity from a private key file (64 bytes).
73pub fn load_identity(path: &Path) -> io::Result<Identity> {
74    let data = fs::read(path)?;
75    if data.len() != 64 {
76        return Err(io::Error::new(
77            io::ErrorKind::InvalidData,
78            format!("Identity file must be 64 bytes, got {}", data.len()),
79        ));
80    }
81    let mut key = [0u8; 64];
82    key.copy_from_slice(&data);
83    Ok(Identity::from_private_key(&key))
84}
85
86/// Save known destinations to a msgpack file.
87///
88/// Format: `{bytes(16): [received_at, public_key, app_data, identity_hash, hops,
89/// receiving_interface, was_used, last_used_at, retained], ...}`
90///
91/// Legacy 4-element arrays are still accepted on load.
92pub fn save_known_destinations(
93    destinations: &HashMap<[u8; 16], KnownDestination>,
94    path: &Path,
95) -> io::Result<()> {
96    use rns_core::msgpack::{self, Value};
97
98    let entries: Vec<(Value, Value)> = destinations
99        .iter()
100        .map(|(hash, dest)| {
101            let key = Value::Bin(hash.to_vec());
102            let app_data = match &dest.app_data {
103                Some(d) => Value::Bin(d.clone()),
104                None => Value::Nil,
105            };
106            let value = Value::Array(vec![
107                Value::UInt(dest.received_at as u64),
108                Value::Bin(dest.public_key.to_vec()),
109                app_data,
110                Value::Bin(dest.identity_hash.to_vec()),
111                Value::UInt(dest.hops as u64),
112                Value::UInt(dest.receiving_interface),
113                Value::Bool(dest.was_used),
114                match dest.last_used_at {
115                    Some(last_used_at) => Value::UInt(last_used_at as u64),
116                    None => Value::Nil,
117                },
118                Value::Bool(dest.retained),
119            ]);
120            (key, value)
121        })
122        .collect();
123
124    let packed = msgpack::pack(&Value::Map(entries));
125    fs::write(path, packed)
126}
127
128/// Load known destinations from a msgpack file.
129pub fn load_known_destinations(path: &Path) -> io::Result<HashMap<[u8; 16], KnownDestination>> {
130    use rns_core::msgpack;
131
132    let data = fs::read(path)?;
133    if data.is_empty() {
134        return Ok(HashMap::new());
135    }
136
137    let (value, _) = msgpack::unpack(&data)
138        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e)))?;
139
140    let map = value
141        .as_map()
142        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected msgpack map"))?;
143
144    let mut result = HashMap::new();
145
146    for (k, v) in map {
147        let hash_bytes = k
148            .as_bin()
149            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin key"))?;
150
151        if hash_bytes.len() != 16 {
152            continue; // Skip invalid entries like Python does
153        }
154
155        let mut dest_hash = [0u8; 16];
156        dest_hash.copy_from_slice(hash_bytes);
157
158        let arr = v
159            .as_array()
160            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected array value"))?;
161
162        if arr.len() < 3 {
163            continue;
164        }
165
166        let received_at = arr[0].as_uint().unwrap_or(0) as f64;
167
168        let pub_key_bytes = arr[1]
169            .as_bin()
170            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin public_key"))?;
171        if pub_key_bytes.len() != 64 {
172            continue;
173        }
174        let mut public_key = [0u8; 64];
175        public_key.copy_from_slice(pub_key_bytes);
176
177        let app_data = if arr.len() > 2 {
178            arr[2].as_bin().map(|b| b.to_vec())
179        } else {
180            None
181        };
182
183        let identity_hash = if arr.len() > 3 {
184            let hash_bytes = arr[3]
185                .as_bin()
186                .filter(|bytes| bytes.len() == 16)
187                .map(|bytes| {
188                    let mut hash = [0u8; 16];
189                    hash.copy_from_slice(bytes);
190                    hash
191                });
192            hash_bytes.unwrap_or_else(|| {
193                let identity = Identity::from_public_key(&public_key);
194                *identity.hash()
195            })
196        } else {
197            let identity = Identity::from_public_key(&public_key);
198            *identity.hash()
199        };
200        let hops = arr.get(4).and_then(|value| value.as_uint()).unwrap_or(0) as u8;
201        let receiving_interface = arr.get(5).and_then(|value| value.as_uint()).unwrap_or(0);
202        let was_used = arr
203            .get(6)
204            .and_then(|value| value.as_bool())
205            .unwrap_or(false);
206        let last_used_at = arr
207            .get(7)
208            .and_then(|value| value.as_uint())
209            .map(|value| value as f64);
210        let retained = arr
211            .get(8)
212            .and_then(|value| value.as_bool())
213            .unwrap_or(false);
214
215        result.insert(
216            dest_hash,
217            KnownDestination {
218                identity_hash,
219                public_key,
220                app_data,
221                hops,
222                received_at,
223                receiving_interface,
224                was_used,
225                last_used_at,
226                retained,
227            },
228        );
229    }
230
231    Ok(result)
232}
233
234/// Resolve the config directory path.
235/// Priority: explicit path > `~/.reticulum/`
236pub fn resolve_config_dir(explicit: Option<&Path>) -> PathBuf {
237    if let Some(p) = explicit {
238        p.to_path_buf()
239    } else {
240        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
241        PathBuf::from(home).join(".reticulum")
242    }
243}
244
245/// Load or create an identity at the standard location.
246pub fn load_or_create_identity(identities_dir: &Path) -> io::Result<Identity> {
247    let id_path = identities_dir.join("identity");
248    if id_path.exists() {
249        load_identity(&id_path)
250    } else {
251        let identity = Identity::new(&mut OsRng);
252        save_identity(&identity, &id_path)?;
253        Ok(identity)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    use std::sync::atomic::{AtomicU64, Ordering};
262    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
263
264    fn temp_dir() -> PathBuf {
265        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
266        let dir = std::env::temp_dir().join(format!("rns-test-{}-{}", std::process::id(), id));
267        let _ = fs::remove_dir_all(&dir);
268        fs::create_dir_all(&dir).unwrap();
269        dir
270    }
271
272    #[test]
273    fn save_load_identity_roundtrip() {
274        let dir = temp_dir();
275        let path = dir.join("test_identity");
276
277        let identity = Identity::new(&mut OsRng);
278        let original_hash = *identity.hash();
279
280        save_identity(&identity, &path).unwrap();
281        let loaded = load_identity(&path).unwrap();
282
283        assert_eq!(*loaded.hash(), original_hash);
284
285        let _ = fs::remove_dir_all(&dir);
286    }
287
288    #[test]
289    fn identity_file_format() {
290        let dir = temp_dir();
291        let path = dir.join("test_identity_fmt");
292
293        let identity = Identity::new(&mut OsRng);
294        save_identity(&identity, &path).unwrap();
295
296        let data = fs::read(&path).unwrap();
297        assert_eq!(data.len(), 64, "Identity file must be exactly 64 bytes");
298
299        // First 32 bytes: X25519 private key
300        // Next 32 bytes: Ed25519 private key (seed)
301        let private_key = identity.get_private_key();
302        let private_key = private_key.unwrap();
303        assert_eq!(&data[..], &private_key[..]);
304
305        let _ = fs::remove_dir_all(&dir);
306    }
307
308    #[test]
309    fn save_load_known_destinations_empty() {
310        let dir = temp_dir();
311        let path = dir.join("known_destinations");
312
313        let empty: HashMap<[u8; 16], KnownDestination> = HashMap::new();
314        save_known_destinations(&empty, &path).unwrap();
315
316        let loaded = load_known_destinations(&path).unwrap();
317        assert!(loaded.is_empty());
318
319        let _ = fs::remove_dir_all(&dir);
320    }
321
322    #[test]
323    fn save_load_known_destinations_roundtrip() {
324        let dir = temp_dir();
325        let path = dir.join("known_destinations");
326
327        let mut dests = HashMap::new();
328        dests.insert(
329            [0x01u8; 16],
330            KnownDestination {
331                identity_hash: [0x11u8; 16],
332                public_key: [0xABu8; 64],
333                app_data: Some(vec![0x01, 0x02, 0x03]),
334                hops: 2,
335                received_at: 1700000000.0,
336                receiving_interface: 7,
337                was_used: true,
338                last_used_at: Some(1700000010.0),
339                retained: true,
340            },
341        );
342        dests.insert(
343            [0x02u8; 16],
344            KnownDestination {
345                identity_hash: [0x22u8; 16],
346                public_key: [0xCDu8; 64],
347                app_data: None,
348                hops: 1,
349                received_at: 1700000001.0,
350                receiving_interface: 0,
351                was_used: false,
352                last_used_at: None,
353                retained: false,
354            },
355        );
356
357        save_known_destinations(&dests, &path).unwrap();
358        let loaded = load_known_destinations(&path).unwrap();
359
360        assert_eq!(loaded.len(), 2);
361
362        let d1 = &loaded[&[0x01u8; 16]];
363        assert_eq!(d1.identity_hash, [0x11u8; 16]);
364        assert_eq!(d1.public_key, [0xABu8; 64]);
365        assert_eq!(d1.app_data, Some(vec![0x01, 0x02, 0x03]));
366        assert_eq!(d1.hops, 2);
367        assert_eq!(d1.received_at as u64, 1700000000);
368        assert_eq!(d1.receiving_interface, 7);
369        assert!(d1.was_used);
370        assert_eq!(d1.last_used_at, Some(1700000010.0));
371        assert!(d1.retained);
372
373        let d2 = &loaded[&[0x02u8; 16]];
374        assert_eq!(d2.app_data, None);
375        assert!(!d2.was_used);
376        assert_eq!(d2.last_used_at, None);
377        assert!(!d2.retained);
378
379        let _ = fs::remove_dir_all(&dir);
380    }
381
382    #[test]
383    fn ensure_dirs_creates() {
384        let dir = temp_dir().join("new_config");
385        let _ = fs::remove_dir_all(&dir);
386
387        let paths = ensure_storage_dirs(&dir).unwrap();
388
389        assert!(paths.storage.exists());
390        assert!(paths.cache.exists());
391        assert!(paths.identities.exists());
392        assert!(paths.discovered_interfaces.exists());
393
394        let _ = fs::remove_dir_all(&dir);
395    }
396
397    #[test]
398    fn ensure_dirs_existing() {
399        let dir = temp_dir().join("existing_config");
400        fs::create_dir_all(dir.join("storage")).unwrap();
401        fs::create_dir_all(dir.join("cache")).unwrap();
402
403        let paths = ensure_storage_dirs(&dir).unwrap();
404        assert!(paths.storage.exists());
405        assert!(paths.identities.exists());
406
407        let _ = fs::remove_dir_all(&dir);
408    }
409
410    #[test]
411    fn load_or_create_identity_new() {
412        let dir = temp_dir().join("load_or_create");
413        fs::create_dir_all(&dir).unwrap();
414
415        let identity = load_or_create_identity(&dir).unwrap();
416        let id_path = dir.join("identity");
417        assert!(id_path.exists());
418
419        // Loading again should give same identity
420        let loaded = load_or_create_identity(&dir).unwrap();
421        assert_eq!(*identity.hash(), *loaded.hash());
422
423        let _ = fs::remove_dir_all(&dir);
424    }
425}