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.get_private_key().ok_or_else(|| {
62        io::Error::new(io::ErrorKind::InvalidData, "Identity has no private key")
63    })?;
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(
118    path: &Path,
119) -> io::Result<HashMap<[u8; 16], KnownDestination>> {
120    use rns_core::msgpack;
121
122    let data = fs::read(path)?;
123    if data.is_empty() {
124        return Ok(HashMap::new());
125    }
126
127    let (value, _) = msgpack::unpack(&data).map_err(|e| {
128        io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e))
129    })?;
130
131    let map = value.as_map().ok_or_else(|| {
132        io::Error::new(io::ErrorKind::InvalidData, "Expected msgpack map")
133    })?;
134
135    let mut result = HashMap::new();
136
137    for (k, v) in map {
138        let hash_bytes = k.as_bin().ok_or_else(|| {
139            io::Error::new(io::ErrorKind::InvalidData, "Expected bin key")
140        })?;
141
142        if hash_bytes.len() != 16 {
143            continue; // Skip invalid entries like Python does
144        }
145
146        let mut dest_hash = [0u8; 16];
147        dest_hash.copy_from_slice(hash_bytes);
148
149        let arr = v.as_array().ok_or_else(|| {
150            io::Error::new(io::ErrorKind::InvalidData, "Expected array value")
151        })?;
152
153        if arr.len() < 3 {
154            continue;
155        }
156
157        let timestamp = arr[0].as_uint().unwrap_or(0) as f64;
158
159        let pkt_hash_bytes = arr[1].as_bin().ok_or_else(|| {
160            io::Error::new(io::ErrorKind::InvalidData, "Expected bin packet_hash")
161        })?;
162        if pkt_hash_bytes.len() != 32 {
163            continue;
164        }
165        let mut packet_hash = [0u8; 32];
166        packet_hash.copy_from_slice(pkt_hash_bytes);
167
168        let pub_key_bytes = arr[2].as_bin().ok_or_else(|| {
169            io::Error::new(io::ErrorKind::InvalidData, "Expected bin public_key")
170        })?;
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() > 3 {
178            arr[3].as_bin().map(|b| b.to_vec())
179        } else {
180            None
181        };
182
183        result.insert(
184            dest_hash,
185            KnownDestination {
186                timestamp,
187                packet_hash,
188                public_key,
189                app_data,
190            },
191        );
192    }
193
194    Ok(result)
195}
196
197/// Resolve the config directory path.
198/// Priority: explicit path > `~/.reticulum/`
199pub fn resolve_config_dir(explicit: Option<&Path>) -> PathBuf {
200    if let Some(p) = explicit {
201        p.to_path_buf()
202    } else {
203        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
204        PathBuf::from(home).join(".reticulum")
205    }
206}
207
208/// Load or create an identity at the standard location.
209pub fn load_or_create_identity(identities_dir: &Path) -> io::Result<Identity> {
210    let id_path = identities_dir.join("identity");
211    if id_path.exists() {
212        load_identity(&id_path)
213    } else {
214        let identity = Identity::new(&mut OsRng);
215        save_identity(&identity, &id_path)?;
216        Ok(identity)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    use std::sync::atomic::{AtomicU64, Ordering};
225    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
226
227    fn temp_dir() -> PathBuf {
228        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
229        let dir = std::env::temp_dir().join(format!(
230            "rns-test-{}-{}",
231            std::process::id(),
232            id
233        ));
234        let _ = fs::remove_dir_all(&dir);
235        fs::create_dir_all(&dir).unwrap();
236        dir
237    }
238
239    #[test]
240    fn save_load_identity_roundtrip() {
241        let dir = temp_dir();
242        let path = dir.join("test_identity");
243
244        let identity = Identity::new(&mut OsRng);
245        let original_hash = *identity.hash();
246
247        save_identity(&identity, &path).unwrap();
248        let loaded = load_identity(&path).unwrap();
249
250        assert_eq!(*loaded.hash(), original_hash);
251
252        let _ = fs::remove_dir_all(&dir);
253    }
254
255    #[test]
256    fn identity_file_format() {
257        let dir = temp_dir();
258        let path = dir.join("test_identity_fmt");
259
260        let identity = Identity::new(&mut OsRng);
261        save_identity(&identity, &path).unwrap();
262
263        let data = fs::read(&path).unwrap();
264        assert_eq!(data.len(), 64, "Identity file must be exactly 64 bytes");
265
266        // First 32 bytes: X25519 private key
267        // Next 32 bytes: Ed25519 private key (seed)
268        let private_key = identity.get_private_key();
269        let private_key = private_key.unwrap();
270        assert_eq!(&data[..], &private_key[..]);
271
272        let _ = fs::remove_dir_all(&dir);
273    }
274
275    #[test]
276    fn save_load_known_destinations_empty() {
277        let dir = temp_dir();
278        let path = dir.join("known_destinations");
279
280        let empty: HashMap<[u8; 16], KnownDestination> = HashMap::new();
281        save_known_destinations(&empty, &path).unwrap();
282
283        let loaded = load_known_destinations(&path).unwrap();
284        assert!(loaded.is_empty());
285
286        let _ = fs::remove_dir_all(&dir);
287    }
288
289    #[test]
290    fn save_load_known_destinations_roundtrip() {
291        let dir = temp_dir();
292        let path = dir.join("known_destinations");
293
294        let mut dests = HashMap::new();
295        dests.insert(
296            [0x01u8; 16],
297            KnownDestination {
298                timestamp: 1700000000.0,
299                packet_hash: [0x42u8; 32],
300                public_key: [0xABu8; 64],
301                app_data: Some(vec![0x01, 0x02, 0x03]),
302            },
303        );
304        dests.insert(
305            [0x02u8; 16],
306            KnownDestination {
307                timestamp: 1700000001.0,
308                packet_hash: [0x43u8; 32],
309                public_key: [0xCDu8; 64],
310                app_data: None,
311            },
312        );
313
314        save_known_destinations(&dests, &path).unwrap();
315        let loaded = load_known_destinations(&path).unwrap();
316
317        assert_eq!(loaded.len(), 2);
318
319        let d1 = &loaded[&[0x01u8; 16]];
320        assert_eq!(d1.timestamp as u64, 1700000000);
321        assert_eq!(d1.packet_hash, [0x42u8; 32]);
322        assert_eq!(d1.public_key, [0xABu8; 64]);
323        assert_eq!(d1.app_data, Some(vec![0x01, 0x02, 0x03]));
324
325        let d2 = &loaded[&[0x02u8; 16]];
326        assert_eq!(d2.app_data, None);
327
328        let _ = fs::remove_dir_all(&dir);
329    }
330
331    #[test]
332    fn ensure_dirs_creates() {
333        let dir = temp_dir().join("new_config");
334        let _ = fs::remove_dir_all(&dir);
335
336        let paths = ensure_storage_dirs(&dir).unwrap();
337
338        assert!(paths.storage.exists());
339        assert!(paths.cache.exists());
340        assert!(paths.identities.exists());
341        assert!(paths.discovered_interfaces.exists());
342
343        let _ = fs::remove_dir_all(&dir);
344    }
345
346    #[test]
347    fn ensure_dirs_existing() {
348        let dir = temp_dir().join("existing_config");
349        fs::create_dir_all(dir.join("storage")).unwrap();
350        fs::create_dir_all(dir.join("cache")).unwrap();
351
352        let paths = ensure_storage_dirs(&dir).unwrap();
353        assert!(paths.storage.exists());
354        assert!(paths.identities.exists());
355
356        let _ = fs::remove_dir_all(&dir);
357    }
358
359    #[test]
360    fn load_or_create_identity_new() {
361        let dir = temp_dir().join("load_or_create");
362        fs::create_dir_all(&dir).unwrap();
363
364        let identity = load_or_create_identity(&dir).unwrap();
365        let id_path = dir.join("identity");
366        assert!(id_path.exists());
367
368        // Loading again should give same identity
369        let loaded = load_or_create_identity(&dir).unwrap();
370        assert_eq!(*identity.hash(), *loaded.hash());
371
372        let _ = fs::remove_dir_all(&dir);
373    }
374}