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