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