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