Skip to main content

rns_net/
announce_cache.rs

1//! Announce cache for disk persistence.
2//!
3//! Caches announce packets to disk for path request responses.
4//! File format matches Python: msgpack `[raw_bytes, interface_name_or_nil]`.
5//! Filename: hex-encoded packet_hash (64 chars).
6//!
7//! Python reference: Transport.py:2334-2402
8
9use std::fs;
10use std::io;
11use std::path::PathBuf;
12
13use rns_core::msgpack::{self, Value};
14
15/// Announce cache backed by filesystem.
16pub struct AnnounceCache {
17    base_path: PathBuf,
18}
19
20impl AnnounceCache {
21    /// Create an announce cache at the given directory.
22    /// The directory must already exist (created by `ensure_storage_dirs`).
23    pub fn new(base_path: PathBuf) -> Self {
24        AnnounceCache { base_path }
25    }
26
27    /// Store a cached announce to disk.
28    ///
29    /// `packet_hash`: 32-byte packet hash (used as filename)
30    /// `raw`: raw announce bytes (pre-hop-increment)
31    /// `interface_name`: optional interface name string
32    pub fn store(
33        &self,
34        packet_hash: &[u8; 32],
35        raw: &[u8],
36        interface_name: Option<&str>,
37    ) -> io::Result<()> {
38        let filename = hex_encode(packet_hash);
39        let path = self.base_path.join(&filename);
40
41        let iface_val = match interface_name {
42            Some(name) => Value::Str(name.into()),
43            None => Value::Nil,
44        };
45        let data = msgpack::pack(&Value::Array(vec![
46            Value::Bin(raw.to_vec()),
47            iface_val,
48        ]));
49
50        fs::write(path, data)
51    }
52
53    /// Retrieve a cached announce from disk.
54    ///
55    /// Returns `(raw_bytes, interface_name_or_none)`.
56    pub fn get(&self, packet_hash: &[u8; 32]) -> io::Result<Option<(Vec<u8>, Option<String>)>> {
57        let filename = hex_encode(packet_hash);
58        let path = self.base_path.join(&filename);
59
60        if !path.is_file() {
61            return Ok(None);
62        }
63
64        let data = fs::read(&path)?;
65        let (value, _) = msgpack::unpack(&data).map_err(|e| {
66            io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e))
67        })?;
68
69        let arr = value.as_array().ok_or_else(|| {
70            io::Error::new(io::ErrorKind::InvalidData, "Expected msgpack array")
71        })?;
72
73        if arr.is_empty() {
74            return Ok(None);
75        }
76
77        let raw = arr[0].as_bin().ok_or_else(|| {
78            io::Error::new(io::ErrorKind::InvalidData, "Expected bin raw bytes")
79        })?;
80
81        let iface_name = if arr.len() > 1 {
82            arr[1].as_str().map(|s| s.to_string())
83        } else {
84            None
85        };
86
87        Ok(Some((raw.to_vec(), iface_name)))
88    }
89
90    /// Remove cached announces whose packet hashes are not in the active set.
91    ///
92    /// `active_hashes`: set of packet hashes that should be kept.
93    /// Returns the number of removed entries.
94    pub fn clean(&self, active_hashes: &[[u8; 32]]) -> io::Result<usize> {
95        let entries = match fs::read_dir(&self.base_path) {
96            Ok(e) => e,
97            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
98            Err(e) => return Err(e),
99        };
100
101        let mut removed = 0;
102        for entry in entries {
103            let entry = entry?;
104            let path = entry.path();
105            if !path.is_file() {
106                continue;
107            }
108
109            let filename = match path.file_name().and_then(|n| n.to_str()) {
110                Some(n) => n,
111                None => continue,
112            };
113
114            // Parse hex filename back to hash
115            match hex_decode(filename) {
116                Some(hash) => {
117                    if !active_hashes.contains(&hash) {
118                        let _ = fs::remove_file(&path);
119                        removed += 1;
120                    }
121                }
122                None => {
123                    // Invalid filename — remove
124                    let _ = fs::remove_file(&path);
125                    removed += 1;
126                }
127            }
128        }
129
130        Ok(removed)
131    }
132
133    /// Get the base path for testing.
134    #[cfg(test)]
135    pub fn base_path(&self) -> &std::path::Path {
136        &self.base_path
137    }
138}
139
140/// Encode 32 bytes as 64-char lowercase hex string.
141fn hex_encode(bytes: &[u8; 32]) -> String {
142    let mut s = String::with_capacity(64);
143    for b in bytes {
144        s.push(HEX_CHARS[(b >> 4) as usize]);
145        s.push(HEX_CHARS[(b & 0x0f) as usize]);
146    }
147    s
148}
149
150const HEX_CHARS: [char; 16] = [
151    '0', '1', '2', '3', '4', '5', '6', '7',
152    '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
153];
154
155/// Decode a 64-char hex string back to 32 bytes.
156fn hex_decode(s: &str) -> Option<[u8; 32]> {
157    if s.len() != 64 {
158        return None;
159    }
160    let mut result = [0u8; 32];
161    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
162        let high = hex_nibble(chunk[0])?;
163        let low = hex_nibble(chunk[1])?;
164        result[i] = (high << 4) | low;
165    }
166    Some(result)
167}
168
169fn hex_nibble(c: u8) -> Option<u8> {
170    match c {
171        b'0'..=b'9' => Some(c - b'0'),
172        b'a'..=b'f' => Some(c - b'a' + 10),
173        b'A'..=b'F' => Some(c - b'A' + 10),
174        _ => None,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::sync::atomic::{AtomicU64, Ordering};
182
183    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
184
185    fn temp_dir() -> PathBuf {
186        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
187        let dir = std::env::temp_dir().join(format!(
188            "rns-announce-cache-{}-{}",
189            std::process::id(),
190            id,
191        ));
192        let _ = fs::remove_dir_all(&dir);
193        fs::create_dir_all(&dir).unwrap();
194        dir
195    }
196
197    #[test]
198    fn test_hex_encode_decode_roundtrip() {
199        let hash = [0xAB; 32];
200        let encoded = hex_encode(&hash);
201        assert_eq!(encoded.len(), 64);
202        assert_eq!(encoded.len(), 64);
203        // All bytes are 0xAB so hex is "ab" repeated 32 times
204        assert!(encoded.chars().all(|c| c == 'a' || c == 'b'));
205        let decoded = hex_decode(&encoded).unwrap();
206        assert_eq!(decoded, hash);
207    }
208
209    #[test]
210    fn test_hex_decode_invalid() {
211        assert!(hex_decode("too_short").is_none());
212        assert!(hex_decode(&"zz".repeat(32)).is_none());
213    }
214
215    #[test]
216    fn test_store_and_get_roundtrip() {
217        let dir = temp_dir();
218        let cache = AnnounceCache::new(dir.clone());
219
220        let hash = [0x42; 32];
221        let raw = vec![0x01, 0x02, 0x03, 0x04, 0x05];
222        cache.store(&hash, &raw, Some("TestInterface")).unwrap();
223
224        let result = cache.get(&hash).unwrap();
225        assert!(result.is_some());
226        let (got_raw, got_name) = result.unwrap();
227        assert_eq!(got_raw, raw);
228        assert_eq!(got_name, Some("TestInterface".to_string()));
229
230        let _ = fs::remove_dir_all(&dir);
231    }
232
233    #[test]
234    fn test_store_with_nil_interface() {
235        let dir = temp_dir();
236        let cache = AnnounceCache::new(dir.clone());
237
238        let hash = [0x55; 32];
239        let raw = vec![0xAA, 0xBB];
240        cache.store(&hash, &raw, None).unwrap();
241
242        let result = cache.get(&hash).unwrap();
243        assert!(result.is_some());
244        let (got_raw, got_name) = result.unwrap();
245        assert_eq!(got_raw, raw);
246        assert_eq!(got_name, None);
247
248        let _ = fs::remove_dir_all(&dir);
249    }
250
251    #[test]
252    fn test_get_nonexistent() {
253        let dir = temp_dir();
254        let cache = AnnounceCache::new(dir.clone());
255
256        let hash = [0xFF; 32];
257        let result = cache.get(&hash).unwrap();
258        assert!(result.is_none());
259
260        let _ = fs::remove_dir_all(&dir);
261    }
262
263    #[test]
264    fn test_clean_removes_stale() {
265        let dir = temp_dir();
266        let cache = AnnounceCache::new(dir.clone());
267
268        let hash1 = [0x11; 32];
269        let hash2 = [0x22; 32];
270        let hash3 = [0x33; 32];
271
272        cache.store(&hash1, &[0x01], None).unwrap();
273        cache.store(&hash2, &[0x02], None).unwrap();
274        cache.store(&hash3, &[0x03], None).unwrap();
275
276        // Keep only hash2
277        let removed = cache.clean(&[hash2]).unwrap();
278        assert_eq!(removed, 2);
279
280        // hash2 should still be there
281        assert!(cache.get(&hash2).unwrap().is_some());
282        // hash1 and hash3 should be gone
283        assert!(cache.get(&hash1).unwrap().is_none());
284        assert!(cache.get(&hash3).unwrap().is_none());
285
286        let _ = fs::remove_dir_all(&dir);
287    }
288
289    #[test]
290    fn test_clean_empty_dir() {
291        let dir = temp_dir();
292        let cache = AnnounceCache::new(dir.clone());
293
294        let removed = cache.clean(&[]).unwrap();
295        assert_eq!(removed, 0);
296
297        let _ = fs::remove_dir_all(&dir);
298    }
299
300    #[test]
301    fn test_store_overwrite() {
302        let dir = temp_dir();
303        let cache = AnnounceCache::new(dir.clone());
304
305        let hash = [0x77; 32];
306        cache.store(&hash, &[0x01], Some("iface1")).unwrap();
307        cache.store(&hash, &[0x02, 0x03], Some("iface2")).unwrap();
308
309        let result = cache.get(&hash).unwrap().unwrap();
310        assert_eq!(result.0, vec![0x02, 0x03]);
311        assert_eq!(result.1, Some("iface2".to_string()));
312
313        let _ = fs::remove_dir_all(&dir);
314    }
315}