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