Skip to main content

rns_net/
discovery.rs

1//! Interface Discovery protocol implementation.
2//!
3//! Handles receiving, validating, and storing discovered interface announcements
4//! from other Reticulum nodes on the network.
5//!
6//! Pure types and parsing live in `common::discovery`; this module contains
7//! I/O storage and background-threaded stamp generation / announcing.
8//!
9//! Python reference: RNS/Discovery.py
10
11// Re-export everything from common::discovery so existing `crate::discovery::X` paths work.
12pub use crate::common::discovery::*;
13
14use std::fs;
15use std::io;
16use std::path::PathBuf;
17
18use rns_core::msgpack::{self, Value};
19use rns_core::stamp::{stamp_valid, stamp_workblock};
20use rns_crypto::sha256::sha256;
21
22use crate::time;
23
24// ============================================================================
25// Storage
26// ============================================================================
27
28/// Persistent storage for discovered interfaces
29pub struct DiscoveredInterfaceStorage {
30    base_path: PathBuf,
31}
32
33impl DiscoveredInterfaceStorage {
34    /// Create a new storage instance
35    pub fn new(base_path: PathBuf) -> Self {
36        Self { base_path }
37    }
38
39    /// Store a discovered interface
40    pub fn store(&self, iface: &DiscoveredInterface) -> io::Result<()> {
41        let filename = hex_encode(&iface.discovery_hash);
42        let filepath = self.base_path.join(filename);
43
44        let data = self.serialize_interface(iface)?;
45        fs::write(&filepath, &data)
46    }
47
48    /// Store a newly received interface announce, preserving persistent counters.
49    pub fn store_received(&self, iface: &mut DiscoveredInterface) -> io::Result<()> {
50        match self.load(&iface.discovery_hash) {
51            Ok(Some(existing)) => {
52                iface.discovered = existing.discovered;
53                iface.heard_count = existing.heard_count.saturating_add(1);
54            }
55            Ok(None) => {
56                iface.discovered = iface.last_heard;
57                iface.heard_count = 1;
58            }
59            Err(err) => {
60                log::error!(
61                    "Error while reading existing data for discovered interface, re-creating data: {}",
62                    err
63                );
64                iface.discovered = iface.last_heard;
65                iface.heard_count = 1;
66            }
67        }
68
69        self.store(iface)
70    }
71
72    /// Load a discovered interface by its discovery hash
73    pub fn load(&self, discovery_hash: &[u8; 32]) -> io::Result<Option<DiscoveredInterface>> {
74        let filename = hex_encode(discovery_hash);
75        let filepath = self.base_path.join(filename);
76
77        if !filepath.exists() {
78            return Ok(None);
79        }
80
81        let data = fs::read(&filepath)?;
82        self.deserialize_interface(&data).map(Some)
83    }
84
85    /// List all discovered interfaces
86    pub fn list(&self) -> io::Result<Vec<DiscoveredInterface>> {
87        let mut interfaces = Vec::new();
88
89        let entries = match fs::read_dir(&self.base_path) {
90            Ok(e) => e,
91            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(interfaces),
92            Err(e) => return Err(e),
93        };
94
95        for entry in entries {
96            let entry = entry?;
97            let path = entry.path();
98
99            if !path.is_file() {
100                continue;
101            }
102
103            match fs::read(&path) {
104                Ok(data) => {
105                    if let Ok(iface) = self.deserialize_interface(&data) {
106                        interfaces.push(iface);
107                    }
108                }
109                Err(_) => continue,
110            }
111        }
112
113        Ok(interfaces)
114    }
115
116    /// Remove a discovered interface by its discovery hash
117    pub fn remove(&self, discovery_hash: &[u8; 32]) -> io::Result<()> {
118        let filename = hex_encode(discovery_hash);
119        let filepath = self.base_path.join(filename);
120
121        if filepath.exists() {
122            fs::remove_file(&filepath)?;
123        }
124        Ok(())
125    }
126
127    /// Clean up stale entries (older than THRESHOLD_REMOVE)
128    /// Returns the number of entries removed
129    pub fn cleanup(&self) -> io::Result<usize> {
130        let mut removed = 0;
131        let now = time::now();
132
133        let interfaces = self.list()?;
134        for iface in interfaces {
135            let invalid_reachable_on = iface
136                .reachable_on
137                .as_ref()
138                .map(|reachable_on| !(is_ip_address(reachable_on) || is_hostname(reachable_on)))
139                .unwrap_or(false);
140
141            if !is_discoverable_type(&iface.interface_type)
142                || invalid_reachable_on
143                || now - iface.last_heard > THRESHOLD_REMOVE
144            {
145                self.remove(&iface.discovery_hash)?;
146                removed += 1;
147            }
148        }
149
150        Ok(removed)
151    }
152
153    /// Serialize an interface to msgpack
154    fn serialize_interface(&self, iface: &DiscoveredInterface) -> io::Result<Vec<u8>> {
155        let mut entries: Vec<(Value, Value)> = Vec::new();
156
157        entries.push((
158            Value::Str("type".into()),
159            Value::Str(iface.interface_type.clone()),
160        ));
161        entries.push((Value::Str("transport".into()), Value::Bool(iface.transport)));
162        entries.push((Value::Str("name".into()), Value::Str(iface.name.clone())));
163        entries.push((
164            Value::Str("discovered".into()),
165            Value::Float(iface.discovered),
166        ));
167        entries.push((
168            Value::Str("last_heard".into()),
169            Value::Float(iface.last_heard),
170        ));
171        entries.push((
172            Value::Str("heard_count".into()),
173            Value::UInt(iface.heard_count as u64),
174        ));
175        entries.push((
176            Value::Str("status".into()),
177            Value::Str(iface.status.as_str().into()),
178        ));
179        entries.push((Value::Str("stamp".into()), Value::Bin(iface.stamp.clone())));
180        entries.push((
181            Value::Str("value".into()),
182            Value::UInt(iface.stamp_value as u64),
183        ));
184        entries.push((
185            Value::Str("transport_id".into()),
186            Value::Bin(iface.transport_id.to_vec()),
187        ));
188        entries.push((
189            Value::Str("network_id".into()),
190            Value::Bin(iface.network_id.to_vec()),
191        ));
192        entries.push((Value::Str("hops".into()), Value::UInt(iface.hops as u64)));
193
194        if let Some(v) = iface.latitude {
195            entries.push((Value::Str("latitude".into()), Value::Float(v)));
196        }
197        if let Some(v) = iface.longitude {
198            entries.push((Value::Str("longitude".into()), Value::Float(v)));
199        }
200        if let Some(v) = iface.height {
201            entries.push((Value::Str("height".into()), Value::Float(v)));
202        }
203        if let Some(ref v) = iface.reachable_on {
204            entries.push((Value::Str("reachable_on".into()), Value::Str(v.clone())));
205        }
206        if let Some(v) = iface.port {
207            entries.push((Value::Str("port".into()), Value::UInt(v as u64)));
208        }
209        if let Some(v) = iface.frequency {
210            entries.push((Value::Str("frequency".into()), Value::UInt(v as u64)));
211        }
212        if let Some(v) = iface.bandwidth {
213            entries.push((Value::Str("bandwidth".into()), Value::UInt(v as u64)));
214        }
215        if let Some(v) = iface.spreading_factor {
216            entries.push((Value::Str("sf".into()), Value::UInt(v as u64)));
217        }
218        if let Some(v) = iface.coding_rate {
219            entries.push((Value::Str("cr".into()), Value::UInt(v as u64)));
220        }
221        if let Some(ref v) = iface.modulation {
222            entries.push((Value::Str("modulation".into()), Value::Str(v.clone())));
223        }
224        if let Some(v) = iface.channel {
225            entries.push((Value::Str("channel".into()), Value::UInt(v as u64)));
226        }
227        if let Some(ref v) = iface.ifac_netname {
228            entries.push((Value::Str("ifac_netname".into()), Value::Str(v.clone())));
229        }
230        if let Some(ref v) = iface.ifac_netkey {
231            entries.push((Value::Str("ifac_netkey".into()), Value::Str(v.clone())));
232        }
233        if let Some(ref v) = iface.config_entry {
234            entries.push((Value::Str("config_entry".into()), Value::Str(v.clone())));
235        }
236
237        entries.push((
238            Value::Str("discovery_hash".into()),
239            Value::Bin(iface.discovery_hash.to_vec()),
240        ));
241
242        Ok(msgpack::pack(&Value::Map(entries)))
243    }
244
245    /// Deserialize an interface from msgpack
246    fn deserialize_interface(&self, data: &[u8]) -> io::Result<DiscoveredInterface> {
247        let (value, _) = msgpack::unpack(data).map_err(|e| {
248            io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e))
249        })?;
250
251        // Helper functions using map_get
252        let get_str = |v: &Value, key: &str| -> io::Result<String> {
253            v.map_get(key)
254                .and_then(|val| val.as_str())
255                .map(|s| s.to_string())
256                .ok_or_else(|| {
257                    io::Error::new(io::ErrorKind::InvalidData, format!("{} not a string", key))
258                })
259        };
260
261        let get_opt_str = |v: &Value, key: &str| -> Option<String> {
262            v.map_get(key)
263                .and_then(|val| val.as_str().map(|s| s.to_string()))
264        };
265
266        let get_bool = |v: &Value, key: &str| -> io::Result<bool> {
267            v.map_get(key).and_then(|val| val.as_bool()).ok_or_else(|| {
268                io::Error::new(io::ErrorKind::InvalidData, format!("{} not a bool", key))
269            })
270        };
271
272        let get_float = |v: &Value, key: &str| -> io::Result<f64> {
273            v.map_get(key)
274                .and_then(|val| val.as_float())
275                .ok_or_else(|| {
276                    io::Error::new(io::ErrorKind::InvalidData, format!("{} not a float", key))
277                })
278        };
279
280        let get_opt_float =
281            |v: &Value, key: &str| -> Option<f64> { v.map_get(key).and_then(|val| val.as_float()) };
282
283        let get_uint = |v: &Value, key: &str| -> io::Result<u64> {
284            v.map_get(key).and_then(|val| val.as_uint()).ok_or_else(|| {
285                io::Error::new(io::ErrorKind::InvalidData, format!("{} not a uint", key))
286            })
287        };
288
289        let get_opt_uint =
290            |v: &Value, key: &str| -> Option<u64> { v.map_get(key).and_then(|val| val.as_uint()) };
291
292        let get_bytes = |v: &Value, key: &str| -> io::Result<Vec<u8>> {
293            v.map_get(key)
294                .and_then(|val| val.as_bin())
295                .map(|b| b.to_vec())
296                .ok_or_else(|| {
297                    io::Error::new(io::ErrorKind::InvalidData, format!("{} not bytes", key))
298                })
299        };
300
301        let fixed_bytes = |key: &str, expected_len: usize| -> io::Result<Vec<u8>> {
302            let bytes = get_bytes(&value, key)?;
303            if bytes.len() != expected_len {
304                return Err(io::Error::new(
305                    io::ErrorKind::InvalidData,
306                    format!("{} must be {} bytes", key, expected_len),
307                ));
308            }
309            Ok(bytes)
310        };
311
312        let transport_id_bytes = fixed_bytes("transport_id", 16)?;
313        let mut transport_id = [0u8; 16];
314        transport_id.copy_from_slice(&transport_id_bytes);
315
316        let network_id_bytes = fixed_bytes("network_id", 16)?;
317        let mut network_id = [0u8; 16];
318        network_id.copy_from_slice(&network_id_bytes);
319
320        let discovery_hash_bytes = fixed_bytes("discovery_hash", 32)?;
321        let mut discovery_hash = [0u8; 32];
322        discovery_hash.copy_from_slice(&discovery_hash_bytes);
323
324        let status_str = get_str(&value, "status")?;
325        let status = match status_str.as_str() {
326            "available" => DiscoveredStatus::Available,
327            "unknown" => DiscoveredStatus::Unknown,
328            "stale" => DiscoveredStatus::Stale,
329            _ => DiscoveredStatus::Unknown,
330        };
331
332        let interface_type = get_str(&value, "type")?;
333        let raw_name = get_str(&value, "name")?;
334        let name = sanitize_discovered_name(&raw_name)
335            .unwrap_or_else(|| format!("Discovered {}", interface_type));
336
337        Ok(DiscoveredInterface {
338            interface_type,
339            transport: get_bool(&value, "transport")?,
340            name,
341            discovered: get_float(&value, "discovered")?,
342            last_heard: get_float(&value, "last_heard")?,
343            heard_count: get_uint(&value, "heard_count")? as u32,
344            status,
345            stamp: get_bytes(&value, "stamp")?,
346            stamp_value: get_uint(&value, "value")? as u32,
347            transport_id,
348            network_id,
349            hops: get_uint(&value, "hops")? as u8,
350            latitude: get_opt_float(&value, "latitude"),
351            longitude: get_opt_float(&value, "longitude"),
352            height: get_opt_float(&value, "height"),
353            reachable_on: get_opt_str(&value, "reachable_on"),
354            port: get_opt_uint(&value, "port").map(|v| v as u16),
355            frequency: get_opt_uint(&value, "frequency").map(|v| v as u32),
356            bandwidth: get_opt_uint(&value, "bandwidth").map(|v| v as u32),
357            spreading_factor: get_opt_uint(&value, "sf").map(|v| v as u8),
358            coding_rate: get_opt_uint(&value, "cr").map(|v| v as u8),
359            modulation: get_opt_str(&value, "modulation"),
360            channel: get_opt_uint(&value, "channel").map(|v| v as u8),
361            ifac_netname: get_opt_str(&value, "ifac_netname"),
362            ifac_netkey: get_opt_str(&value, "ifac_netkey"),
363            config_entry: get_opt_str(&value, "config_entry"),
364            discovery_hash,
365        })
366    }
367}
368
369// ============================================================================
370// Stamp Generation (parallel PoW search)
371// ============================================================================
372
373/// Generate a discovery stamp with the given cost using rayon parallel iterators.
374///
375/// Returns `(stamp, value)` on success. This is a blocking, CPU-intensive operation.
376pub fn generate_discovery_stamp(packed_data: &[u8], stamp_cost: u8) -> ([u8; STAMP_SIZE], u32) {
377    use rns_crypto::{OsRng, Rng};
378    use std::sync::atomic::{AtomicBool, Ordering};
379    use std::sync::{Arc, Mutex};
380
381    let infohash = sha256(packed_data);
382    let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
383
384    let found: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
385    let result: Arc<Mutex<Option<[u8; STAMP_SIZE]>>> = Arc::new(Mutex::new(None));
386
387    let num_threads = rayon::current_num_threads();
388
389    rayon::scope(|s| {
390        for _ in 0..num_threads {
391            let found = found.clone();
392            let result = result.clone();
393            let workblock = &workblock;
394            s.spawn(move |_| {
395                let mut rng = OsRng;
396                let mut nonce = [0u8; STAMP_SIZE];
397                loop {
398                    if found.load(Ordering::Relaxed) {
399                        return;
400                    }
401                    rng.fill_bytes(&mut nonce);
402                    if stamp_valid(&nonce, stamp_cost, workblock) {
403                        let mut r = match result.lock() {
404                            Ok(guard) => guard,
405                            Err(poisoned) => {
406                                log::error!(
407                                    "recovering from poisoned discovery stamp result buffer"
408                                );
409                                poisoned.into_inner()
410                            }
411                        };
412                        if r.is_none() {
413                            *r = Some(nonce);
414                        }
415                        found.store(true, Ordering::Relaxed);
416                        return;
417                    }
418                }
419            });
420        }
421    });
422
423    let stamp = match result.lock() {
424        Ok(mut guard) => guard.take(),
425        Err(poisoned) => {
426            log::error!("recovering from poisoned discovery stamp result buffer");
427            poisoned.into_inner().take()
428        }
429    }
430    .unwrap_or_else(|| {
431        log::error!("parallel discovery stamp search returned no result; retrying synchronously");
432        let mut rng = OsRng;
433        let mut nonce = [0u8; STAMP_SIZE];
434        loop {
435            rng.fill_bytes(&mut nonce);
436            if stamp_valid(&nonce, stamp_cost, &workblock) {
437                return nonce;
438            }
439        }
440    });
441    let value = rns_core::stamp::stamp_value(&workblock, &stamp);
442    (stamp, value)
443}
444
445// ============================================================================
446// Interface Announcer
447// ============================================================================
448
449/// Info about a single discoverable interface, ready for announcing.
450#[derive(Debug, Clone)]
451pub struct DiscoverableInterface {
452    /// Configured interface name used for runtime targeting.
453    pub interface_name: String,
454    pub config: DiscoveryConfig,
455    /// Whether the node has transport enabled.
456    pub transport_enabled: bool,
457    /// IFAC network name, if configured.
458    pub ifac_netname: Option<String>,
459    /// IFAC passphrase, if configured.
460    pub ifac_netkey: Option<String>,
461}
462
463/// Result of a completed background stamp generation.
464pub struct StampResult {
465    /// Configured interface name this stamp was generated for.
466    pub interface_name: String,
467    /// The complete app_data: [flags][packed][stamp].
468    pub app_data: Vec<u8>,
469}
470
471/// Manages periodic announcing of discoverable interfaces.
472///
473/// Stamp generation (PoW) runs on a background thread so it never blocks the
474/// driver event loop.  The driver calls `poll_ready()` each tick to collect
475/// finished results.
476pub struct InterfaceAnnouncer {
477    /// Transport identity hash (16 bytes).
478    transport_id: [u8; 16],
479    /// Discoverable interfaces with their configs.
480    interfaces: Vec<DiscoverableInterface>,
481    /// Last announce time per interface (indexed same as `interfaces`).
482    last_announced: Vec<f64>,
483    /// Receiver for completed stamp results from background threads.
484    stamp_rx: std::sync::mpsc::Receiver<StampResult>,
485    /// Sender cloned into background threads.
486    stamp_tx: std::sync::mpsc::Sender<StampResult>,
487    /// Whether a background stamp job is currently running.
488    stamp_pending: bool,
489}
490
491impl InterfaceAnnouncer {
492    /// Create a new announcer.
493    pub fn new(transport_id: [u8; 16], interfaces: Vec<DiscoverableInterface>) -> Self {
494        let n = interfaces.len();
495        let (stamp_tx, stamp_rx) = std::sync::mpsc::channel();
496        InterfaceAnnouncer {
497            transport_id,
498            interfaces,
499            last_announced: vec![0.0; n],
500            stamp_rx,
501            stamp_tx,
502            stamp_pending: false,
503        }
504    }
505
506    /// If any interface is due for an announce and no stamp job is already
507    /// running, spawns a background thread for PoW.  The result will be
508    /// available via `poll_ready()`.
509    pub fn maybe_start(&mut self, now: f64) {
510        if self.stamp_pending {
511            return;
512        }
513        let due_index = self.interfaces.iter().enumerate().find_map(|(i, iface)| {
514            let elapsed = now - self.last_announced[i];
515            if elapsed >= iface.config.announce_interval as f64 {
516                Some(i)
517            } else {
518                None
519            }
520        });
521
522        if let Some(idx) = due_index {
523            let packed = self.pack_interface_info(idx);
524            let stamp_cost = self.interfaces[idx].config.stamp_value;
525            let name = self.interfaces[idx].config.discovery_name.clone();
526            let interface_name = self.interfaces[idx].interface_name.clone();
527            let tx = self.stamp_tx.clone();
528
529            log::info!(
530                "Spawning discovery stamp generation (cost={}) for '{}'...",
531                stamp_cost,
532                name,
533            );
534
535            self.stamp_pending = true;
536            self.last_announced[idx] = now;
537
538            std::thread::spawn(move || {
539                let (stamp, value) = generate_discovery_stamp(&packed, stamp_cost);
540                log::info!("Discovery stamp generated (value={}) for '{}'", value, name,);
541
542                let flags: u8 = 0x00; // no encryption
543                let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
544                app_data.push(flags);
545                app_data.extend_from_slice(&packed);
546                app_data.extend_from_slice(&stamp);
547
548                let _ = tx.send(StampResult {
549                    interface_name,
550                    app_data,
551                });
552            });
553        }
554    }
555
556    /// Non-blocking poll: returns completed app_data if a background stamp
557    /// job has finished.
558    pub fn poll_ready(&mut self) -> Option<StampResult> {
559        match self.stamp_rx.try_recv() {
560            Ok(result) => {
561                self.stamp_pending = false;
562                Some(result)
563            }
564            Err(_) => None,
565        }
566    }
567
568    /// Returns true if the announcer currently tracks a discoverable interface by name.
569    pub fn contains_interface(&self, interface_name: &str) -> bool {
570        self.interfaces
571            .iter()
572            .any(|iface| iface.interface_name == interface_name)
573    }
574
575    /// Insert or update a discoverable interface by configured name.
576    pub fn upsert_interface(&mut self, iface: DiscoverableInterface) {
577        if let Some(index) = self
578            .interfaces
579            .iter()
580            .position(|existing| existing.interface_name == iface.interface_name)
581        {
582            self.interfaces[index] = iface;
583            return;
584        }
585        self.interfaces.push(iface);
586        self.last_announced.push(0.0);
587    }
588
589    /// Remove a discoverable interface by configured name.
590    pub fn remove_interface(&mut self, interface_name: &str) -> bool {
591        if let Some(index) = self
592            .interfaces
593            .iter()
594            .position(|iface| iface.interface_name == interface_name)
595        {
596            self.interfaces.remove(index);
597            self.last_announced.remove(index);
598            true
599        } else {
600            false
601        }
602    }
603
604    /// Returns true if no discoverable interfaces remain.
605    pub fn is_empty(&self) -> bool {
606        self.interfaces.is_empty()
607    }
608
609    /// Pack interface metadata as msgpack map with integer keys.
610    fn pack_interface_info(&self, index: usize) -> Vec<u8> {
611        let iface = &self.interfaces[index];
612        let mut entries: Vec<(msgpack::Value, msgpack::Value)> = Vec::new();
613
614        entries.push((
615            msgpack::Value::UInt(INTERFACE_TYPE as u64),
616            msgpack::Value::Str(iface.config.interface_type.clone()),
617        ));
618        entries.push((
619            msgpack::Value::UInt(TRANSPORT as u64),
620            msgpack::Value::Bool(iface.transport_enabled),
621        ));
622        entries.push((
623            msgpack::Value::UInt(NAME as u64),
624            msgpack::Value::Str(iface.config.discovery_name.clone()),
625        ));
626        entries.push((
627            msgpack::Value::UInt(TRANSPORT_ID as u64),
628            msgpack::Value::Bin(self.transport_id.to_vec()),
629        ));
630        if let Some(ref reachable) = iface.config.reachable_on {
631            entries.push((
632                msgpack::Value::UInt(REACHABLE_ON as u64),
633                msgpack::Value::Str(reachable.clone()),
634            ));
635        }
636        if let Some(port) = iface.config.listen_port {
637            entries.push((
638                msgpack::Value::UInt(PORT as u64),
639                msgpack::Value::UInt(port as u64),
640            ));
641        }
642        if let Some(lat) = iface.config.latitude {
643            entries.push((
644                msgpack::Value::UInt(LATITUDE as u64),
645                msgpack::Value::Float(lat),
646            ));
647        }
648        if let Some(lon) = iface.config.longitude {
649            entries.push((
650                msgpack::Value::UInt(LONGITUDE as u64),
651                msgpack::Value::Float(lon),
652            ));
653        }
654        if let Some(h) = iface.config.height {
655            entries.push((
656                msgpack::Value::UInt(HEIGHT as u64),
657                msgpack::Value::Float(h),
658            ));
659        }
660        if let Some(ref netname) = iface.ifac_netname {
661            entries.push((
662                msgpack::Value::UInt(IFAC_NETNAME as u64),
663                msgpack::Value::Str(netname.clone()),
664            ));
665        }
666        if let Some(ref netkey) = iface.ifac_netkey {
667            entries.push((
668                msgpack::Value::UInt(IFAC_NETKEY as u64),
669                msgpack::Value::Str(netkey.clone()),
670            ));
671        }
672
673        msgpack::pack(&msgpack::Value::Map(entries))
674    }
675}
676
677// ============================================================================
678// Tests
679// ============================================================================
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_hex_encode() {
687        assert_eq!(hex_encode(&[0x00, 0xff, 0x12]), "00ff12");
688        assert_eq!(hex_encode(&[]), "");
689    }
690
691    #[test]
692    fn test_compute_discovery_hash() {
693        let transport_id = [0x42u8; 16];
694        let name = "TestInterface";
695        let hash = compute_discovery_hash(&transport_id, name);
696
697        // Should be deterministic
698        let hash2 = compute_discovery_hash(&transport_id, name);
699        assert_eq!(hash, hash2);
700
701        // Different name should give different hash
702        let hash3 = compute_discovery_hash(&transport_id, "OtherInterface");
703        assert_ne!(hash, hash3);
704    }
705
706    #[test]
707    fn test_is_ip_address() {
708        assert!(is_ip_address("192.168.1.1"));
709        assert!(is_ip_address("::1"));
710        assert!(is_ip_address("2001:db8::1"));
711        assert!(!is_ip_address("not-an-ip"));
712        assert!(!is_ip_address("hostname.example.com"));
713    }
714
715    #[test]
716    fn test_is_hostname() {
717        assert!(is_hostname("example.com"));
718        assert!(is_hostname("sub.example.com"));
719        assert!(is_hostname("my-node"));
720        assert!(is_hostname("my-node.example.com"));
721        assert!(!is_hostname(""));
722        assert!(!is_hostname("-invalid"));
723        assert!(!is_hostname("invalid-"));
724        assert!(!is_hostname("a".repeat(300).as_str()));
725    }
726
727    #[test]
728    fn test_discovered_status() {
729        let now = time::now();
730
731        let mut iface = DiscoveredInterface {
732            interface_type: "TestInterface".into(),
733            transport: true,
734            name: "Test".into(),
735            discovered: now,
736            last_heard: now,
737            heard_count: 0,
738            status: DiscoveredStatus::Available,
739            stamp: vec![],
740            stamp_value: 14,
741            transport_id: [0u8; 16],
742            network_id: [0u8; 16],
743            hops: 0,
744            latitude: None,
745            longitude: None,
746            height: None,
747            reachable_on: None,
748            port: None,
749            frequency: None,
750            bandwidth: None,
751            spreading_factor: None,
752            coding_rate: None,
753            modulation: None,
754            channel: None,
755            ifac_netname: None,
756            ifac_netkey: None,
757            config_entry: None,
758            discovery_hash: [0u8; 32],
759        };
760
761        // Fresh interface should be available
762        assert_eq!(iface.compute_status(), DiscoveredStatus::Available);
763
764        // 25 hours old should be unknown
765        iface.last_heard = now - THRESHOLD_UNKNOWN - 3600.0;
766        assert_eq!(iface.compute_status(), DiscoveredStatus::Unknown);
767
768        // 4 days old should be stale
769        iface.last_heard = now - THRESHOLD_STALE - 3600.0;
770        assert_eq!(iface.compute_status(), DiscoveredStatus::Stale);
771    }
772
773    fn test_discovered_interface(name: &str) -> DiscoveredInterface {
774        DiscoveredInterface {
775            interface_type: "BackboneInterface".into(),
776            transport: true,
777            name: name.into(),
778            discovered: 1700000000.0,
779            last_heard: 1700001000.0,
780            heard_count: 5,
781            status: DiscoveredStatus::Available,
782            stamp: vec![0x42u8; 64],
783            stamp_value: 18,
784            transport_id: [0x01u8; 16],
785            network_id: [0x02u8; 16],
786            hops: 2,
787            latitude: Some(45.0),
788            longitude: Some(9.0),
789            height: Some(100.0),
790            reachable_on: Some("example.com".into()),
791            port: Some(4242),
792            frequency: None,
793            bandwidth: None,
794            spreading_factor: None,
795            coding_rate: None,
796            modulation: None,
797            channel: None,
798            ifac_netname: Some("mynetwork".into()),
799            ifac_netkey: Some("secretkey".into()),
800            config_entry: Some("test config".into()),
801            discovery_hash: compute_discovery_hash(&[0x01u8; 16], name),
802        }
803    }
804
805    #[test]
806    fn test_storage_roundtrip() {
807        use std::sync::atomic::{AtomicU64, Ordering};
808        static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
809
810        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
811        let dir =
812            std::env::temp_dir().join(format!("rns-discovery-test-{}-{}", std::process::id(), id));
813        let _ = fs::remove_dir_all(&dir);
814        fs::create_dir_all(&dir).unwrap();
815
816        let storage = DiscoveredInterfaceStorage::new(dir.clone());
817
818        let iface = test_discovered_interface("TestNode");
819
820        // Store
821        storage.store(&iface).unwrap();
822
823        // Load
824        let loaded = storage.load(&iface.discovery_hash).unwrap().unwrap();
825
826        assert_eq!(loaded.interface_type, iface.interface_type);
827        assert_eq!(loaded.name, iface.name);
828        assert_eq!(loaded.stamp_value, iface.stamp_value);
829        assert_eq!(loaded.transport_id, iface.transport_id);
830        assert_eq!(loaded.hops, iface.hops);
831        assert_eq!(loaded.latitude, iface.latitude);
832        assert_eq!(loaded.reachable_on, iface.reachable_on);
833        assert_eq!(loaded.port, iface.port);
834
835        // List
836        let list = storage.list().unwrap();
837        assert_eq!(list.len(), 1);
838
839        // Remove
840        storage.remove(&iface.discovery_hash).unwrap();
841        let list = storage.list().unwrap();
842        assert!(list.is_empty());
843
844        let _ = fs::remove_dir_all(&dir);
845    }
846
847    #[test]
848    fn storage_load_sanitizes_cached_interface_names() {
849        let dir = std::env::temp_dir().join(format!(
850            "rns-discovery-sanitize-test-{}",
851            std::process::id()
852        ));
853        let _ = fs::remove_dir_all(&dir);
854        fs::create_dir_all(&dir).unwrap();
855        let storage = DiscoveredInterfaceStorage::new(dir.clone());
856        let iface = test_discovered_interface("\t**Cached     Name!!!\n");
857
858        storage.store(&iface).unwrap();
859
860        let loaded = storage.load(&iface.discovery_hash).unwrap().unwrap();
861        let listed = storage.list().unwrap();
862
863        assert_eq!(loaded.name, "Cached Name");
864        assert_eq!(listed[0].name, "Cached Name");
865
866        let _ = fs::remove_dir_all(&dir);
867    }
868
869    #[test]
870    fn storage_rejects_cached_transport_id_with_invalid_length() {
871        let storage = DiscoveredInterfaceStorage::new(std::env::temp_dir());
872        let iface = test_discovered_interface("BadTransportId");
873        let mut data = storage.serialize_interface(&iface).unwrap();
874        let (mut value, _) = msgpack::unpack(&data).unwrap();
875        if let Value::Map(ref mut entries) = value {
876            for (key, val) in entries {
877                if key.as_str() == Some("transport_id") {
878                    *val = Value::Bin(vec![0x01; 15]);
879                }
880            }
881        }
882        data = msgpack::pack(&value);
883
884        let err = storage.deserialize_interface(&data).unwrap_err();
885
886        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
887        assert!(err.to_string().contains("transport_id"));
888    }
889
890    #[test]
891    fn store_received_preserves_existing_first_seen_and_increments_heard_count() {
892        let dir = std::env::temp_dir().join(format!(
893            "rns-discovery-received-preserve-test-{}",
894            std::process::id()
895        ));
896        let _ = fs::remove_dir_all(&dir);
897        fs::create_dir_all(&dir).unwrap();
898        let storage = DiscoveredInterfaceStorage::new(dir.clone());
899
900        let mut existing = test_discovered_interface("ExistingDiscovery");
901        existing.discovered = 1000.0;
902        existing.last_heard = 1100.0;
903        existing.heard_count = 7;
904        storage.store(&existing).unwrap();
905
906        let mut received = existing.clone();
907        received.discovered = 2000.0;
908        received.last_heard = 3000.0;
909        received.heard_count = 0;
910        storage.store_received(&mut received).unwrap();
911
912        let loaded = storage.load(&received.discovery_hash).unwrap().unwrap();
913        assert_eq!(received.discovered, 1000.0);
914        assert_eq!(received.last_heard, 3000.0);
915        assert_eq!(received.heard_count, 8);
916        assert_eq!(loaded.discovered, 1000.0);
917        assert_eq!(loaded.last_heard, 3000.0);
918        assert_eq!(loaded.heard_count, 8);
919
920        let _ = fs::remove_dir_all(&dir);
921    }
922
923    #[test]
924    fn store_received_recreates_corrupt_cache_with_received_time_as_first_seen() {
925        let dir = std::env::temp_dir().join(format!(
926            "rns-discovery-corrupt-recreate-test-{}",
927            std::process::id()
928        ));
929        let _ = fs::remove_dir_all(&dir);
930        fs::create_dir_all(&dir).unwrap();
931        let storage = DiscoveredInterfaceStorage::new(dir.clone());
932
933        let mut received = test_discovered_interface("CorruptDiscovery");
934        received.discovered = 1234.0;
935        received.last_heard = 5678.0;
936        received.heard_count = 0;
937        let filepath = dir.join(hex_encode(&received.discovery_hash));
938        fs::write(&filepath, b"not msgpack").unwrap();
939
940        storage.store_received(&mut received).unwrap();
941
942        let loaded = storage.load(&received.discovery_hash).unwrap().unwrap();
943        assert_eq!(received.discovered, 5678.0);
944        assert_eq!(received.heard_count, 1);
945        assert_eq!(loaded.discovered, 5678.0);
946        assert_eq!(loaded.last_heard, 5678.0);
947        assert_eq!(loaded.heard_count, 1);
948        assert_eq!(loaded.name, "CorruptDiscovery");
949
950        let _ = fs::remove_dir_all(&dir);
951    }
952
953    #[test]
954    fn test_filter_and_sort() {
955        let now = time::now();
956
957        let ifaces = vec![
958            DiscoveredInterface {
959                interface_type: "BackboneInterface".into(),
960                transport: true,
961                name: "high-value-stale".into(),
962                discovered: now,
963                last_heard: now - THRESHOLD_STALE - 100.0, // Stale
964                heard_count: 0,
965                status: DiscoveredStatus::Stale,
966                stamp: vec![],
967                stamp_value: 20,
968                transport_id: [0u8; 16],
969                network_id: [0u8; 16],
970                hops: 0,
971                latitude: None,
972                longitude: None,
973                height: None,
974                reachable_on: None,
975                port: None,
976                frequency: None,
977                bandwidth: None,
978                spreading_factor: None,
979                coding_rate: None,
980                modulation: None,
981                channel: None,
982                ifac_netname: None,
983                ifac_netkey: None,
984                config_entry: None,
985                discovery_hash: [0u8; 32],
986            },
987            DiscoveredInterface {
988                interface_type: "TCPServerInterface".into(),
989                transport: true,
990                name: "low-value-available".into(),
991                discovered: now,
992                last_heard: now - 10.0, // Available
993                heard_count: 0,
994                status: DiscoveredStatus::Available,
995                stamp: vec![],
996                stamp_value: 10,
997                transport_id: [0u8; 16],
998                network_id: [0u8; 16],
999                hops: 0,
1000                latitude: None,
1001                longitude: None,
1002                height: None,
1003                reachable_on: None,
1004                port: None,
1005                frequency: None,
1006                bandwidth: None,
1007                spreading_factor: None,
1008                coding_rate: None,
1009                modulation: None,
1010                channel: None,
1011                ifac_netname: None,
1012                ifac_netkey: None,
1013                config_entry: None,
1014                discovery_hash: [1u8; 32],
1015            },
1016            DiscoveredInterface {
1017                interface_type: "I2PInterface".into(),
1018                transport: false,
1019                name: "high-value-available".into(),
1020                discovered: now,
1021                last_heard: now - 10.0, // Available
1022                heard_count: 0,
1023                status: DiscoveredStatus::Available,
1024                stamp: vec![],
1025                stamp_value: 20,
1026                transport_id: [0u8; 16],
1027                network_id: [0u8; 16],
1028                hops: 0,
1029                latitude: None,
1030                longitude: None,
1031                height: None,
1032                reachable_on: None,
1033                port: None,
1034                frequency: None,
1035                bandwidth: None,
1036                spreading_factor: None,
1037                coding_rate: None,
1038                modulation: None,
1039                channel: None,
1040                ifac_netname: None,
1041                ifac_netkey: None,
1042                config_entry: None,
1043                discovery_hash: [2u8; 32],
1044            },
1045        ];
1046
1047        // Test no filter — all included, sorted by status then value
1048        let mut result = ifaces.clone();
1049        filter_and_sort_interfaces(&mut result, false, false);
1050        assert_eq!(result.len(), 3);
1051        // Available ones should come first (higher status code)
1052        assert_eq!(result[0].name, "high-value-available");
1053        assert_eq!(result[1].name, "low-value-available");
1054        assert_eq!(result[2].name, "high-value-stale");
1055
1056        // Test only_available filter
1057        let mut result = ifaces.clone();
1058        filter_and_sort_interfaces(&mut result, true, false);
1059        assert_eq!(result.len(), 2); // stale one filtered out
1060
1061        // Test only_transport filter
1062        let mut result = ifaces.clone();
1063        filter_and_sort_interfaces(&mut result, false, true);
1064        assert_eq!(result.len(), 2); // non-transport one filtered out
1065    }
1066
1067    #[test]
1068    fn test_discovery_name_hash_deterministic() {
1069        let h1 = discovery_name_hash();
1070        let h2 = discovery_name_hash();
1071        assert_eq!(h1, h2);
1072        assert_ne!(h1, [0u8; 10]); // not all zeros
1073    }
1074}