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 timestamp: f64,
31    pub packet_hash: [u8; 32],
32    pub public_key: [u8; 64],
33    pub app_data: Option<Vec<u8>>,
34}
35
36/// Ensure all storage directories exist. Creates them if missing.
37pub fn ensure_storage_dirs(config_dir: &Path) -> io::Result<StoragePaths> {
38    let storage = config_dir.join("storage");
39    let cache = config_dir.join("cache");
40    let identities = storage.join("identities");
41    let announces = cache.join("announces");
42    let discovered_interfaces = storage.join("discovery").join("interfaces");
43
44    fs::create_dir_all(&storage)?;
45    fs::create_dir_all(&cache)?;
46    fs::create_dir_all(&identities)?;
47    fs::create_dir_all(&announces)?;
48    fs::create_dir_all(&discovered_interfaces)?;
49
50    Ok(StoragePaths {
51        config_dir: config_dir.to_path_buf(),
52        storage,
53        cache,
54        identities,
55        discovered_interfaces,
56    })
57}
58
59/// Save an identity's private key to a file (64 bytes).
60pub fn save_identity(identity: &Identity, path: &Path) -> io::Result<()> {
61    let private_key = identity
62        .get_private_key()
63        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Identity has no private key"))?;
64    fs::write(path, &private_key)
65}
66
67/// Load an identity from a private key file (64 bytes).
68pub fn load_identity(path: &Path) -> io::Result<Identity> {
69    let data = fs::read(path)?;
70    if data.len() != 64 {
71        return Err(io::Error::new(
72            io::ErrorKind::InvalidData,
73            format!("Identity file must be 64 bytes, got {}", data.len()),
74        ));
75    }
76    let mut key = [0u8; 64];
77    key.copy_from_slice(&data);
78    Ok(Identity::from_private_key(&key))
79}
80
81/// Save known destinations to a msgpack file.
82///
83/// Format matches Python: `{bytes(16): [timestamp, packet_hash, public_key, app_data], ...}`
84pub fn save_known_destinations(
85    destinations: &HashMap<[u8; 16], KnownDestination>,
86    path: &Path,
87) -> io::Result<()> {
88    use rns_core::msgpack::{self, Value};
89
90    let entries: Vec<(Value, Value)> = destinations
91        .iter()
92        .map(|(hash, dest)| {
93            let key = Value::Bin(hash.to_vec());
94            let app_data = match &dest.app_data {
95                Some(d) => Value::Bin(d.clone()),
96                None => Value::Nil,
97            };
98            let value = Value::Array(vec![
99                // Python uses float for timestamp
100                // msgpack doesn't have native float in our codec, use uint (seconds)
101                // Actually Python stores as float via umsgpack. We'll store the integer
102                // part as uint for now (lossy but functional for interop basics).
103                Value::UInt(dest.timestamp as u64),
104                Value::Bin(dest.packet_hash.to_vec()),
105                Value::Bin(dest.public_key.to_vec()),
106                app_data,
107            ]);
108            (key, value)
109        })
110        .collect();
111
112    let packed = msgpack::pack(&Value::Map(entries));
113    fs::write(path, packed)
114}
115
116/// Load known destinations from a msgpack file.
117pub fn load_known_destinations(path: &Path) -> io::Result<HashMap<[u8; 16], KnownDestination>> {
118    use rns_core::msgpack;
119
120    let data = fs::read(path)?;
121    if data.is_empty() {
122        return Ok(HashMap::new());
123    }
124
125    let (value, _) = msgpack::unpack(&data)
126        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e)))?;
127
128    let map = value
129        .as_map()
130        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected msgpack map"))?;
131
132    let mut result = HashMap::new();
133
134    for (k, v) in map {
135        let hash_bytes = k
136            .as_bin()
137            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin key"))?;
138
139        if hash_bytes.len() != 16 {
140            continue; // Skip invalid entries like Python does
141        }
142
143        let mut dest_hash = [0u8; 16];
144        dest_hash.copy_from_slice(hash_bytes);
145
146        let arr = v
147            .as_array()
148            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected array value"))?;
149
150        if arr.len() < 3 {
151            continue;
152        }
153
154        let timestamp = arr[0].as_uint().unwrap_or(0) as f64;
155
156        let pkt_hash_bytes = arr[1].as_bin().ok_or_else(|| {
157            io::Error::new(io::ErrorKind::InvalidData, "Expected bin packet_hash")
158        })?;
159        if pkt_hash_bytes.len() != 32 {
160            continue;
161        }
162        let mut packet_hash = [0u8; 32];
163        packet_hash.copy_from_slice(pkt_hash_bytes);
164
165        let pub_key_bytes = arr[2]
166            .as_bin()
167            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Expected bin public_key"))?;
168        if pub_key_bytes.len() != 64 {
169            continue;
170        }
171        let mut public_key = [0u8; 64];
172        public_key.copy_from_slice(pub_key_bytes);
173
174        let app_data = if arr.len() > 3 {
175            arr[3].as_bin().map(|b| b.to_vec())
176        } else {
177            None
178        };
179
180        result.insert(
181            dest_hash,
182            KnownDestination {
183                timestamp,
184                packet_hash,
185                public_key,
186                app_data,
187            },
188        );
189    }
190
191    Ok(result)
192}
193
194/// Resolve the config directory path.
195/// Priority: explicit path > `~/.reticulum/`
196pub fn resolve_config_dir(explicit: Option<&Path>) -> PathBuf {
197    if let Some(p) = explicit {
198        p.to_path_buf()
199    } else {
200        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
201        PathBuf::from(home).join(".reticulum")
202    }
203}
204
205/// Load or create an identity at the standard location.
206pub fn load_or_create_identity(identities_dir: &Path) -> io::Result<Identity> {
207    let id_path = identities_dir.join("identity");
208    if id_path.exists() {
209        load_identity(&id_path)
210    } else {
211        let identity = Identity::new(&mut OsRng);
212        save_identity(&identity, &id_path)?;
213        Ok(identity)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    use std::sync::atomic::{AtomicU64, Ordering};
222    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
223
224    fn temp_dir() -> PathBuf {
225        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
226        let dir = std::env::temp_dir().join(format!("rns-test-{}-{}", std::process::id(), id));
227        let _ = fs::remove_dir_all(&dir);
228        fs::create_dir_all(&dir).unwrap();
229        dir
230    }
231
232    #[test]
233    fn save_load_identity_roundtrip() {
234        let dir = temp_dir();
235        let path = dir.join("test_identity");
236
237        let identity = Identity::new(&mut OsRng);
238        let original_hash = *identity.hash();
239
240        save_identity(&identity, &path).unwrap();
241        let loaded = load_identity(&path).unwrap();
242
243        assert_eq!(*loaded.hash(), original_hash);
244
245        let _ = fs::remove_dir_all(&dir);
246    }
247
248    #[test]
249    fn identity_file_format() {
250        let dir = temp_dir();
251        let path = dir.join("test_identity_fmt");
252
253        let identity = Identity::new(&mut OsRng);
254        save_identity(&identity, &path).unwrap();
255
256        let data = fs::read(&path).unwrap();
257        assert_eq!(data.len(), 64, "Identity file must be exactly 64 bytes");
258
259        // First 32 bytes: X25519 private key
260        // Next 32 bytes: Ed25519 private key (seed)
261        let private_key = identity.get_private_key();
262        let private_key = private_key.unwrap();
263        assert_eq!(&data[..], &private_key[..]);
264
265        let _ = fs::remove_dir_all(&dir);
266    }
267
268    #[test]
269    fn save_load_known_destinations_empty() {
270        let dir = temp_dir();
271        let path = dir.join("known_destinations");
272
273        let empty: HashMap<[u8; 16], KnownDestination> = HashMap::new();
274        save_known_destinations(&empty, &path).unwrap();
275
276        let loaded = load_known_destinations(&path).unwrap();
277        assert!(loaded.is_empty());
278
279        let _ = fs::remove_dir_all(&dir);
280    }
281
282    #[test]
283    fn save_load_known_destinations_roundtrip() {
284        let dir = temp_dir();
285        let path = dir.join("known_destinations");
286
287        let mut dests = HashMap::new();
288        dests.insert(
289            [0x01u8; 16],
290            KnownDestination {
291                timestamp: 1700000000.0,
292                packet_hash: [0x42u8; 32],
293                public_key: [0xABu8; 64],
294                app_data: Some(vec![0x01, 0x02, 0x03]),
295            },
296        );
297        dests.insert(
298            [0x02u8; 16],
299            KnownDestination {
300                timestamp: 1700000001.0,
301                packet_hash: [0x43u8; 32],
302                public_key: [0xCDu8; 64],
303                app_data: None,
304            },
305        );
306
307        save_known_destinations(&dests, &path).unwrap();
308        let loaded = load_known_destinations(&path).unwrap();
309
310        assert_eq!(loaded.len(), 2);
311
312        let d1 = &loaded[&[0x01u8; 16]];
313        assert_eq!(d1.timestamp as u64, 1700000000);
314        assert_eq!(d1.packet_hash, [0x42u8; 32]);
315        assert_eq!(d1.public_key, [0xABu8; 64]);
316        assert_eq!(d1.app_data, Some(vec![0x01, 0x02, 0x03]));
317
318        let d2 = &loaded[&[0x02u8; 16]];
319        assert_eq!(d2.app_data, None);
320
321        let _ = fs::remove_dir_all(&dir);
322    }
323
324    #[test]
325    fn ensure_dirs_creates() {
326        let dir = temp_dir().join("new_config");
327        let _ = fs::remove_dir_all(&dir);
328
329        let paths = ensure_storage_dirs(&dir).unwrap();
330
331        assert!(paths.storage.exists());
332        assert!(paths.cache.exists());
333        assert!(paths.identities.exists());
334        assert!(paths.discovered_interfaces.exists());
335
336        let _ = fs::remove_dir_all(&dir);
337    }
338
339    #[test]
340    fn ensure_dirs_existing() {
341        let dir = temp_dir().join("existing_config");
342        fs::create_dir_all(dir.join("storage")).unwrap();
343        fs::create_dir_all(dir.join("cache")).unwrap();
344
345        let paths = ensure_storage_dirs(&dir).unwrap();
346        assert!(paths.storage.exists());
347        assert!(paths.identities.exists());
348
349        let _ = fs::remove_dir_all(&dir);
350    }
351
352    #[test]
353    fn load_or_create_identity_new() {
354        let dir = temp_dir().join("load_or_create");
355        fs::create_dir_all(&dir).unwrap();
356
357        let identity = load_or_create_identity(&dir).unwrap();
358        let id_path = dir.join("identity");
359        assert!(id_path.exists());
360
361        // Loading again should give same identity
362        let loaded = load_or_create_identity(&dir).unwrap();
363        assert_eq!(*identity.hash(), *loaded.hash());
364
365        let _ = fs::remove_dir_all(&dir);
366    }
367}