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::{self, Read};
16use std::path::PathBuf;
17use std::process::{Command, Stdio};
18use std::sync::{Mutex, MutexGuard};
19use std::time::{Duration, Instant};
20
21use rns_core::msgpack::{self, Value};
22use rns_core::stamp::{stamp_valid, stamp_workblock};
23use rns_crypto::sha256::sha256;
24
25use crate::time;
26
27// ============================================================================
28// Storage
29// ============================================================================
30
31static DISCOVERY_STORAGE_LOCK: Mutex<()> = Mutex::new(());
32
33/// Persistent storage for discovered interfaces
34pub struct DiscoveredInterfaceStorage {
35    base_path: PathBuf,
36}
37
38impl DiscoveredInterfaceStorage {
39    /// Create a new storage instance
40    pub fn new(base_path: PathBuf) -> Self {
41        Self { base_path }
42    }
43
44    /// Store a discovered interface
45    pub fn store(&self, iface: &DiscoveredInterface) -> io::Result<()> {
46        let _guard = discovery_storage_guard();
47        self.store_unlocked(iface)
48    }
49
50    fn store_unlocked(&self, iface: &DiscoveredInterface) -> io::Result<()> {
51        let filename = hex_encode(&iface.discovery_hash);
52        let filepath = self.base_path.join(filename);
53
54        let data = self.serialize_interface(iface)?;
55        fs::write(&filepath, &data)
56    }
57
58    /// Store a newly received interface announce, preserving persistent counters.
59    pub fn store_received(&self, iface: &mut DiscoveredInterface) -> io::Result<()> {
60        let _guard = discovery_storage_guard();
61        match self.load_unlocked(&iface.discovery_hash) {
62            Ok(Some(existing)) => {
63                iface.discovered = existing.discovered;
64                iface.heard_count = existing.heard_count.saturating_add(1);
65            }
66            Ok(None) => {
67                iface.discovered = iface.last_heard;
68                iface.heard_count = 1;
69            }
70            Err(err) => {
71                log::error!(
72                    "Error while reading existing data for discovered interface, re-creating data: {}",
73                    err
74                );
75                iface.discovered = iface.last_heard;
76                iface.heard_count = 1;
77            }
78        }
79
80        self.store_unlocked(iface)
81    }
82
83    /// Load a discovered interface by its discovery hash
84    pub fn load(&self, discovery_hash: &[u8; 32]) -> io::Result<Option<DiscoveredInterface>> {
85        let _guard = discovery_storage_guard();
86        self.load_unlocked(discovery_hash)
87    }
88
89    fn load_unlocked(&self, discovery_hash: &[u8; 32]) -> io::Result<Option<DiscoveredInterface>> {
90        let filename = hex_encode(discovery_hash);
91        let filepath = self.base_path.join(filename);
92
93        if !filepath.exists() {
94            return Ok(None);
95        }
96
97        let data = fs::read(&filepath)?;
98        self.deserialize_interface(&data).map(Some)
99    }
100
101    /// List all discovered interfaces
102    pub fn list(&self) -> io::Result<Vec<DiscoveredInterface>> {
103        let _guard = discovery_storage_guard();
104        self.list_unlocked()
105    }
106
107    fn list_unlocked(&self) -> io::Result<Vec<DiscoveredInterface>> {
108        let mut interfaces = Vec::new();
109
110        let entries = match fs::read_dir(&self.base_path) {
111            Ok(e) => e,
112            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(interfaces),
113            Err(e) => return Err(e),
114        };
115
116        for entry in entries {
117            let entry = entry?;
118            let path = entry.path();
119
120            if !path.is_file() {
121                continue;
122            }
123
124            match fs::read(&path) {
125                Ok(data) => {
126                    if let Ok(iface) = self.deserialize_interface(&data) {
127                        interfaces.push(iface);
128                    }
129                }
130                Err(_) => continue,
131            }
132        }
133
134        Ok(interfaces)
135    }
136
137    /// Remove a discovered interface by its discovery hash
138    pub fn remove(&self, discovery_hash: &[u8; 32]) -> io::Result<()> {
139        let _guard = discovery_storage_guard();
140        self.remove_unlocked(discovery_hash)
141    }
142
143    fn remove_unlocked(&self, discovery_hash: &[u8; 32]) -> io::Result<()> {
144        let filename = hex_encode(discovery_hash);
145        let filepath = self.base_path.join(filename);
146
147        if filepath.exists() {
148            fs::remove_file(&filepath)?;
149        }
150        Ok(())
151    }
152
153    /// Clean up stale entries (older than THRESHOLD_REMOVE)
154    /// Returns the number of entries removed
155    pub fn cleanup(&self) -> io::Result<usize> {
156        self.cleanup_with_blackholes(|_| false)
157    }
158
159    /// Clean up stale, invalid, or blackholed discovery records.
160    pub fn cleanup_with_blackholes<F>(&self, mut is_blackholed: F) -> io::Result<usize>
161    where
162        F: FnMut(&[u8; 16]) -> bool,
163    {
164        let _guard = discovery_storage_guard();
165        let mut removed = 0;
166        let now = time::now();
167
168        let entries = match fs::read_dir(&self.base_path) {
169            Ok(entries) => entries,
170            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(0),
171            Err(err) => return Err(err),
172        };
173        for entry in entries {
174            let entry = entry?;
175            let path = entry.path();
176            if !path.is_file() {
177                continue;
178            }
179            let iface = match fs::read(&path).and_then(|data| self.deserialize_interface(&data)) {
180                Ok(iface) => iface,
181                Err(err) => {
182                    log::debug!(
183                        "Removing invalid cached interface discovery {}: {}",
184                        path.display(),
185                        err
186                    );
187                    fs::remove_file(&path)?;
188                    removed += 1;
189                    continue;
190                }
191            };
192            let invalid_reachable_on = iface
193                .reachable_on
194                .as_ref()
195                .map(|reachable_on| !(is_ip_address(reachable_on) || is_hostname(reachable_on)))
196                .unwrap_or(false);
197
198            if !is_discoverable_type(&iface.interface_type)
199                || invalid_reachable_on
200                || now - iface.last_heard > THRESHOLD_REMOVE
201                || is_blackholed(&iface.network_id)
202                || is_blackholed(&iface.transport_id)
203            {
204                fs::remove_file(&path)?;
205                removed += 1;
206            }
207        }
208
209        Ok(removed)
210    }
211
212    /// Serialize an interface to msgpack
213    fn serialize_interface(&self, iface: &DiscoveredInterface) -> io::Result<Vec<u8>> {
214        let mut entries: Vec<(Value, Value)> = vec![
215            (
216                Value::Str("type".into()),
217                Value::Str(iface.interface_type.clone()),
218            ),
219            (Value::Str("transport".into()), Value::Bool(iface.transport)),
220            (Value::Str("name".into()), Value::Str(iface.name.clone())),
221            (
222                Value::Str("discovered".into()),
223                Value::Float(iface.discovered),
224            ),
225            (
226                Value::Str("last_heard".into()),
227                Value::Float(iface.last_heard),
228            ),
229            (
230                Value::Str("heard_count".into()),
231                Value::UInt(iface.heard_count as u64),
232            ),
233            (
234                Value::Str("status".into()),
235                Value::Str(iface.status.as_str().into()),
236            ),
237            (Value::Str("stamp".into()), Value::Bin(iface.stamp.clone())),
238            (
239                Value::Str("value".into()),
240                Value::UInt(iface.stamp_value as u64),
241            ),
242            (
243                Value::Str("transport_id".into()),
244                Value::Bin(iface.transport_id.to_vec()),
245            ),
246            (
247                Value::Str("network_id".into()),
248                Value::Bin(iface.network_id.to_vec()),
249            ),
250            (Value::Str("hops".into()), Value::UInt(iface.hops as u64)),
251        ];
252
253        if let Some(v) = iface.latitude {
254            entries.push((Value::Str("latitude".into()), Value::Float(v)));
255        }
256        if let Some(v) = iface.longitude {
257            entries.push((Value::Str("longitude".into()), Value::Float(v)));
258        }
259        if let Some(v) = iface.height {
260            entries.push((Value::Str("height".into()), Value::Float(v)));
261        }
262        if let Some(v) = iface.operator_lxmf_address {
263            entries.push((
264                Value::Str("operator_lxmf_address".into()),
265                Value::Bin(v.to_vec()),
266            ));
267        }
268        if let Some(ref v) = iface.reachable_on {
269            entries.push((Value::Str("reachable_on".into()), Value::Str(v.clone())));
270        }
271        if let Some(v) = iface.port {
272            entries.push((Value::Str("port".into()), Value::UInt(v as u64)));
273        }
274        if let Some(v) = iface.frequency {
275            entries.push((Value::Str("frequency".into()), Value::UInt(v as u64)));
276        }
277        if let Some(v) = iface.bandwidth {
278            entries.push((Value::Str("bandwidth".into()), Value::UInt(v as u64)));
279        }
280        if let Some(v) = iface.spreading_factor {
281            entries.push((Value::Str("sf".into()), Value::UInt(v as u64)));
282        }
283        if let Some(v) = iface.coding_rate {
284            entries.push((Value::Str("cr".into()), Value::UInt(v as u64)));
285        }
286        if let Some(ref v) = iface.modulation {
287            entries.push((Value::Str("modulation".into()), Value::Str(v.clone())));
288        }
289        if let Some(v) = iface.channel {
290            entries.push((Value::Str("channel".into()), Value::UInt(v as u64)));
291        }
292        if let Some(ref v) = iface.ifac_netname {
293            entries.push((Value::Str("ifac_netname".into()), Value::Str(v.clone())));
294        }
295        if let Some(ref v) = iface.ifac_netkey {
296            entries.push((Value::Str("ifac_netkey".into()), Value::Str(v.clone())));
297        }
298        if let Some(ref v) = iface.config_entry {
299            entries.push((Value::Str("config_entry".into()), Value::Str(v.clone())));
300        }
301
302        entries.push((
303            Value::Str("discovery_hash".into()),
304            Value::Bin(iface.discovery_hash.to_vec()),
305        ));
306
307        Ok(msgpack::pack(&Value::Map(entries)))
308    }
309
310    /// Deserialize an interface from msgpack
311    fn deserialize_interface(&self, data: &[u8]) -> io::Result<DiscoveredInterface> {
312        let (value, _) = msgpack::unpack(data).map_err(|e| {
313            io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e))
314        })?;
315
316        // Helper functions using map_get
317        let get_str = |v: &Value, key: &str| -> io::Result<String> {
318            v.map_get(key)
319                .and_then(|val| val.as_str())
320                .map(|s| s.to_string())
321                .ok_or_else(|| {
322                    io::Error::new(io::ErrorKind::InvalidData, format!("{} not a string", key))
323                })
324        };
325
326        let get_opt_str = |v: &Value, key: &str| -> Option<String> {
327            v.map_get(key)
328                .and_then(|val| val.as_str().map(|s| s.to_string()))
329        };
330
331        let get_bool = |v: &Value, key: &str| -> io::Result<bool> {
332            v.map_get(key).and_then(|val| val.as_bool()).ok_or_else(|| {
333                io::Error::new(io::ErrorKind::InvalidData, format!("{} not a bool", key))
334            })
335        };
336
337        let get_float = |v: &Value, key: &str| -> io::Result<f64> {
338            v.map_get(key)
339                .and_then(|val| val.as_float())
340                .ok_or_else(|| {
341                    io::Error::new(io::ErrorKind::InvalidData, format!("{} not a float", key))
342                })
343        };
344
345        let get_opt_float =
346            |v: &Value, key: &str| -> Option<f64> { v.map_get(key).and_then(|val| val.as_float()) };
347
348        let get_uint = |v: &Value, key: &str| -> io::Result<u64> {
349            v.map_get(key).and_then(|val| val.as_uint()).ok_or_else(|| {
350                io::Error::new(io::ErrorKind::InvalidData, format!("{} not a uint", key))
351            })
352        };
353
354        let get_opt_uint =
355            |v: &Value, key: &str| -> Option<u64> { v.map_get(key).and_then(|val| val.as_uint()) };
356
357        let get_bytes = |v: &Value, key: &str| -> io::Result<Vec<u8>> {
358            v.map_get(key)
359                .and_then(|val| val.as_bin())
360                .map(|b| b.to_vec())
361                .ok_or_else(|| {
362                    io::Error::new(io::ErrorKind::InvalidData, format!("{} not bytes", key))
363                })
364        };
365
366        let fixed_bytes = |key: &str, expected_len: usize| -> io::Result<Vec<u8>> {
367            let bytes = get_bytes(&value, key)?;
368            if bytes.len() != expected_len {
369                return Err(io::Error::new(
370                    io::ErrorKind::InvalidData,
371                    format!("{} must be {} bytes", key, expected_len),
372                ));
373            }
374            Ok(bytes)
375        };
376
377        let transport_id_bytes = fixed_bytes("transport_id", 16)?;
378        let mut transport_id = [0u8; 16];
379        transport_id.copy_from_slice(&transport_id_bytes);
380
381        let network_id_bytes = fixed_bytes("network_id", 16)?;
382        let mut network_id = [0u8; 16];
383        network_id.copy_from_slice(&network_id_bytes);
384
385        let discovery_hash_bytes = fixed_bytes("discovery_hash", 32)?;
386        let mut discovery_hash = [0u8; 32];
387        discovery_hash.copy_from_slice(&discovery_hash_bytes);
388
389        let status_str = get_str(&value, "status")?;
390        let status = match status_str.as_str() {
391            "available" => DiscoveredStatus::Available,
392            "unknown" => DiscoveredStatus::Unknown,
393            "stale" => DiscoveredStatus::Stale,
394            _ => DiscoveredStatus::Unknown,
395        };
396
397        let interface_type = get_str(&value, "type")?;
398        let raw_name = get_str(&value, "name")?;
399        let name = sanitize_discovered_name(&raw_name)
400            .unwrap_or_else(|| format!("Discovered {}", interface_type));
401
402        Ok(DiscoveredInterface {
403            interface_type,
404            transport: get_bool(&value, "transport")?,
405            name,
406            discovered: get_float(&value, "discovered")?,
407            last_heard: get_float(&value, "last_heard")?,
408            heard_count: get_uint(&value, "heard_count")? as u32,
409            status,
410            stamp: get_bytes(&value, "stamp")?,
411            stamp_value: get_uint(&value, "value")? as u32,
412            transport_id,
413            network_id,
414            hops: get_uint(&value, "hops")? as u8,
415            latitude: get_opt_float(&value, "latitude"),
416            longitude: get_opt_float(&value, "longitude"),
417            height: get_opt_float(&value, "height"),
418            operator_lxmf_address: value
419                .map_get("operator_lxmf_address")
420                .and_then(Value::as_bin)
421                .filter(|bytes| bytes.len() == 16)
422                .map(|bytes| {
423                    let mut address = [0u8; 16];
424                    address.copy_from_slice(bytes);
425                    address
426                }),
427            reachable_on: get_opt_str(&value, "reachable_on"),
428            port: get_opt_uint(&value, "port").map(|v| v as u16),
429            frequency: get_opt_uint(&value, "frequency").map(|v| v as u32),
430            bandwidth: get_opt_uint(&value, "bandwidth").map(|v| v as u32),
431            spreading_factor: get_opt_uint(&value, "sf").map(|v| v as u8),
432            coding_rate: get_opt_uint(&value, "cr").map(|v| v as u8),
433            modulation: get_opt_str(&value, "modulation"),
434            channel: get_opt_uint(&value, "channel").map(|v| v as u8),
435            ifac_netname: get_opt_str(&value, "ifac_netname"),
436            ifac_netkey: get_opt_str(&value, "ifac_netkey"),
437            config_entry: get_opt_str(&value, "config_entry"),
438            discovery_hash,
439        })
440    }
441}
442
443fn discovery_storage_guard() -> MutexGuard<'static, ()> {
444    match DISCOVERY_STORAGE_LOCK.lock() {
445        Ok(guard) => guard,
446        Err(poisoned) => {
447            log::error!("recovering from poisoned discovery storage lock");
448            poisoned.into_inner()
449        }
450    }
451}
452
453// ============================================================================
454// Stamp Generation (parallel PoW search)
455// ============================================================================
456
457/// Generate a discovery stamp with the given cost using rayon parallel iterators.
458///
459/// Returns `(stamp, value)` on success. This is a blocking, CPU-intensive operation.
460pub fn generate_discovery_stamp(packed_data: &[u8], stamp_cost: u8) -> ([u8; STAMP_SIZE], u32) {
461    use rns_crypto::{OsRng, Rng};
462    use std::sync::atomic::{AtomicBool, Ordering};
463    use std::sync::{Arc, Mutex};
464
465    let infohash = sha256(packed_data);
466    let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
467
468    let found: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
469    let result: Arc<Mutex<Option<[u8; STAMP_SIZE]>>> = Arc::new(Mutex::new(None));
470
471    let num_threads = rayon::current_num_threads();
472
473    rayon::scope(|s| {
474        for _ in 0..num_threads {
475            let found = found.clone();
476            let result = result.clone();
477            let workblock = &workblock;
478            s.spawn(move |_| {
479                let mut rng = OsRng;
480                let mut nonce = [0u8; STAMP_SIZE];
481                loop {
482                    if found.load(Ordering::Relaxed) {
483                        return;
484                    }
485                    rng.fill_bytes(&mut nonce);
486                    if stamp_valid(&nonce, stamp_cost, workblock) {
487                        let mut r = match result.lock() {
488                            Ok(guard) => guard,
489                            Err(poisoned) => {
490                                log::error!(
491                                    "recovering from poisoned discovery stamp result buffer"
492                                );
493                                poisoned.into_inner()
494                            }
495                        };
496                        if r.is_none() {
497                            *r = Some(nonce);
498                        }
499                        found.store(true, Ordering::Relaxed);
500                        return;
501                    }
502                }
503            });
504        }
505    });
506
507    let stamp = match result.lock() {
508        Ok(mut guard) => guard.take(),
509        Err(poisoned) => {
510            log::error!("recovering from poisoned discovery stamp result buffer");
511            poisoned.into_inner().take()
512        }
513    }
514    .unwrap_or_else(|| {
515        log::error!("parallel discovery stamp search returned no result; retrying synchronously");
516        let mut rng = OsRng;
517        let mut nonce = [0u8; STAMP_SIZE];
518        loop {
519            rng.fill_bytes(&mut nonce);
520            if stamp_valid(&nonce, stamp_cost, &workblock) {
521                return nonce;
522            }
523        }
524    });
525    let value = rns_core::stamp::stamp_value(&workblock, &stamp);
526    (stamp, value)
527}
528
529// ============================================================================
530// Interface Announcer
531// ============================================================================
532
533/// Info about a single discoverable interface, ready for announcing.
534#[derive(Debug, Clone)]
535pub struct DiscoverableInterface {
536    /// Configured interface name used for runtime targeting.
537    pub interface_name: String,
538    pub config: DiscoveryConfig,
539    /// Whether the node has transport enabled.
540    pub transport_enabled: bool,
541    /// IFAC network name, if configured.
542    pub ifac_netname: Option<String>,
543    /// IFAC passphrase, if configured.
544    pub ifac_netkey: Option<String>,
545}
546
547/// Result of a completed background announce generation.
548pub struct AnnounceResult {
549    /// Configured interface name this stamp was generated for.
550    pub interface_name: String,
551    /// The complete app_data (`[flags][packed][stamp]`), or the reason this
552    /// announce was suppressed while preparing its metadata.
553    pub app_data: Result<Vec<u8>, String>,
554}
555
556/// Manages periodic announcing of discoverable interfaces.
557///
558/// Stamp generation (PoW) runs on a background thread so it never blocks the
559/// driver event loop.  The driver calls `poll_ready()` each tick to collect
560/// finished results.
561pub struct InterfaceAnnouncer {
562    /// Transport identity hash (16 bytes).
563    transport_id: [u8; 16],
564    /// Discoverable interfaces with their configs.
565    interfaces: Vec<DiscoverableInterface>,
566    /// Last announce time per interface (indexed same as `interfaces`).
567    last_announced: Vec<f64>,
568    /// Receiver for completed stamp results from background threads.
569    stamp_rx: std::sync::mpsc::Receiver<AnnounceResult>,
570    /// Sender cloned into background threads.
571    stamp_tx: std::sync::mpsc::Sender<AnnounceResult>,
572    /// Whether a background stamp job is currently running.
573    stamp_pending: bool,
574}
575
576const LOCATION_CMD_TIMEOUT: Duration = Duration::from_secs(5);
577const LOCATION_CMD_MAX_STDOUT: usize = 4096;
578
579fn parse_location_output(output: &[u8]) -> Result<(f64, f64, f64), String> {
580    if output.len() > LOCATION_CMD_MAX_STDOUT {
581        return Err(format!(
582            "location command output exceeds {} bytes",
583            LOCATION_CMD_MAX_STDOUT
584        ));
585    }
586    let line = std::str::from_utf8(output)
587        .map_err(|error| format!("location command output is not UTF-8: {error}"))?;
588    let line = line.strip_suffix('\n').unwrap_or(line);
589    let line = line.strip_suffix('\r').unwrap_or(line);
590    if line.contains(['\n', '\r']) {
591        return Err("location command must output exactly one line".into());
592    }
593    let values: Vec<&str> = line.split(',').map(str::trim).collect();
594    if values.len() != 3 {
595        return Err(format!(
596            "location command returned {} components; expected 3",
597            values.len()
598        ));
599    }
600    let parse = |value: &str, label: &str| {
601        value
602            .parse::<f64>()
603            .map_err(|error| format!("invalid {label} '{value}': {error}"))
604            .and_then(|number| {
605                number
606                    .is_finite()
607                    .then_some(number)
608                    .ok_or_else(|| format!("{label} must be finite"))
609            })
610    };
611    let latitude = parse(values[0], "latitude")?;
612    let longitude = parse(values[1], "longitude")?;
613    let height = parse(values[2], "height")?;
614    if !(-90.0..=90.0).contains(&latitude) {
615        return Err(format!("latitude {latitude} is outside -90..=90"));
616    }
617    if !(-180.0..=180.0).contains(&longitude) {
618        return Err(format!("longitude {longitude} is outside -180..=180"));
619    }
620    if !(-4000.0..=1_000_000.0).contains(&height) {
621        return Err(format!("height {height} is outside -4000..=1000000"));
622    }
623    Ok((latitude, longitude, height))
624}
625
626#[cfg(unix)]
627fn expand_location_path(
628    command: &str,
629    home: Option<std::ffi::OsString>,
630) -> Result<PathBuf, String> {
631    let path = if command == "~" {
632        home.map(PathBuf::from).ok_or_else(|| {
633            "cannot expand location command '~' because HOME is not set".to_string()
634        })?
635    } else if let Some(suffix) = command.strip_prefix("~/") {
636        let home = home.map(PathBuf::from).ok_or_else(|| {
637            "cannot expand location command '~/' because HOME is not set".to_string()
638        })?;
639        home.join(suffix)
640    } else {
641        PathBuf::from(command)
642    };
643    Ok(path)
644}
645
646#[cfg(unix)]
647fn resolve_location(command: &str) -> Result<(f64, f64, f64), String> {
648    use std::os::unix::fs::PermissionsExt;
649
650    let path = expand_location_path(command, std::env::var_os("HOME"))?;
651    let metadata = fs::metadata(&path)
652        .map_err(|error| format!("cannot inspect '{}': {error}", path.display()))?;
653    if !metadata.is_file() {
654        return Err(format!("'{}' is not a regular file", path.display()));
655    }
656    if metadata.permissions().mode() & 0o111 == 0 {
657        return Err(format!("'{}' is not executable", path.display()));
658    }
659
660    let mut child = Command::new(&path)
661        .stdin(Stdio::null())
662        .stdout(Stdio::piped())
663        .stderr(Stdio::null())
664        .spawn()
665        .map_err(|error| format!("cannot execute '{}': {error}", path.display()))?;
666    let stdout = child
667        .stdout
668        .take()
669        .ok_or_else(|| "location command stdout was unavailable".to_string())?;
670    let reader = std::thread::spawn(move || {
671        let mut output = Vec::new();
672        stdout
673            .take((LOCATION_CMD_MAX_STDOUT + 1) as u64)
674            .read_to_end(&mut output)
675            .map(|_| output)
676    });
677    let started = Instant::now();
678    let status = loop {
679        match child.try_wait() {
680            Ok(Some(status)) => break status,
681            Ok(None) if started.elapsed() < LOCATION_CMD_TIMEOUT => {
682                std::thread::sleep(Duration::from_millis(10));
683            }
684            Ok(None) => {
685                let _ = child.kill();
686                let _ = child.wait();
687                return Err("location command timed out after 5 seconds".into());
688            }
689            Err(error) => {
690                let _ = child.kill();
691                let _ = child.wait();
692                return Err(format!("failed waiting for location command: {error}"));
693            }
694        }
695    };
696    let output = reader
697        .join()
698        .map_err(|_| "location command stdout reader panicked".to_string())?
699        .map_err(|error| format!("failed reading location command stdout: {error}"))?;
700    if !status.success() {
701        return Err(format!("location command exited with {status}"));
702    }
703    parse_location_output(&output)
704}
705
706#[cfg(windows)]
707fn resolve_location(_command: &str) -> Result<(f64, f64, f64), String> {
708    Err("location_cmd is not supported on Windows".into())
709}
710
711fn prepare_interface_info(
712    transport_id: &[u8; 16],
713    iface: &DiscoverableInterface,
714) -> Result<Vec<u8>, String> {
715    let mut resolved = iface.clone();
716    if let Some(command) = &resolved.config.location_cmd {
717        let (latitude, longitude, height) = resolve_location(command)?;
718        resolved.config.latitude = Some(latitude);
719        resolved.config.longitude = Some(longitude);
720        resolved.config.height = Some(height);
721    }
722    InterfaceAnnouncer::pack_interface_info(transport_id, &resolved)
723}
724
725impl InterfaceAnnouncer {
726    /// Create a new announcer.
727    pub fn new(transport_id: [u8; 16], interfaces: Vec<DiscoverableInterface>) -> Self {
728        let n = interfaces.len();
729        let (stamp_tx, stamp_rx) = std::sync::mpsc::channel();
730        InterfaceAnnouncer {
731            transport_id,
732            interfaces,
733            last_announced: vec![0.0; n],
734            stamp_rx,
735            stamp_tx,
736            stamp_pending: false,
737        }
738    }
739
740    /// If any interface is due for an announce and no stamp job is already
741    /// running, spawns a background thread for PoW.  The result will be
742    /// available via `poll_ready()`.
743    pub fn maybe_start(&mut self, now: f64) {
744        if self.stamp_pending {
745            return;
746        }
747        let due_index = self
748            .interfaces
749            .iter()
750            .enumerate()
751            .filter(|(i, iface)| {
752                now - self.last_announced[*i] >= iface.config.announce_interval as f64
753            })
754            .min_by(|(left, _), (right, _)| {
755                self.last_announced[*left].total_cmp(&self.last_announced[*right])
756            })
757            .map(|(i, _)| i);
758
759        if let Some(idx) = due_index {
760            let iface = self.interfaces[idx].clone();
761            let transport_id = self.transport_id;
762            let stamp_cost = iface.config.stamp_value;
763            let name = iface.config.discovery_name.clone();
764            let interface_name = iface.interface_name.clone();
765            let tx = self.stamp_tx.clone();
766
767            log::info!(
768                "Spawning discovery stamp generation (cost={}) for '{}'...",
769                stamp_cost,
770                name,
771            );
772
773            self.stamp_pending = true;
774            self.last_announced[idx] = now;
775
776            std::thread::spawn(move || {
777                let packed = match prepare_interface_info(&transport_id, &iface) {
778                    Ok(packed) => packed,
779                    Err(error) => {
780                        let _ = tx.send(AnnounceResult {
781                            interface_name,
782                            app_data: Err(error),
783                        });
784                        return;
785                    }
786                };
787                let (stamp, value) = generate_discovery_stamp(&packed, stamp_cost);
788                log::info!("Discovery stamp generated (value={}) for '{}'", value, name,);
789
790                let flags: u8 = 0x00; // no encryption
791                let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
792                app_data.push(flags);
793                app_data.extend_from_slice(&packed);
794                app_data.extend_from_slice(&stamp);
795
796                let _ = tx.send(AnnounceResult {
797                    interface_name,
798                    app_data: Ok(app_data),
799                });
800            });
801        }
802    }
803
804    /// Non-blocking poll: returns completed app_data if a background stamp
805    /// job has finished.
806    pub fn poll_ready(&mut self) -> Option<AnnounceResult> {
807        match self.stamp_rx.try_recv() {
808            Ok(result) => {
809                self.stamp_pending = false;
810                Some(result)
811            }
812            Err(_) => None,
813        }
814    }
815
816    /// Returns true if the announcer currently tracks a discoverable interface by name.
817    pub fn contains_interface(&self, interface_name: &str) -> bool {
818        self.interfaces
819            .iter()
820            .any(|iface| iface.interface_name == interface_name)
821    }
822
823    /// Insert or update a discoverable interface by configured name.
824    pub fn upsert_interface(&mut self, iface: DiscoverableInterface) {
825        if let Some(index) = self
826            .interfaces
827            .iter()
828            .position(|existing| existing.interface_name == iface.interface_name)
829        {
830            self.interfaces[index] = iface;
831            return;
832        }
833        self.interfaces.push(iface);
834        self.last_announced.push(0.0);
835    }
836
837    /// Remove a discoverable interface by configured name.
838    pub fn remove_interface(&mut self, interface_name: &str) -> bool {
839        if let Some(index) = self
840            .interfaces
841            .iter()
842            .position(|iface| iface.interface_name == interface_name)
843        {
844            self.interfaces.remove(index);
845            self.last_announced.remove(index);
846            true
847        } else {
848            false
849        }
850    }
851
852    /// Returns true if no discoverable interfaces remain.
853    pub fn is_empty(&self) -> bool {
854        self.interfaces.is_empty()
855    }
856
857    /// Pack interface metadata as msgpack map with integer keys.
858    fn pack_interface_info(
859        transport_id: &[u8; 16],
860        iface: &DiscoverableInterface,
861    ) -> Result<Vec<u8>, String> {
862        if matches!(
863            iface.config.interface_type.as_str(),
864            "BackboneInterface" | "TCPServerInterface"
865        ) && iface.config.reachable_on.is_none()
866        {
867            return Err(format!(
868                "{} discovery requires a reachable_on address",
869                iface.config.interface_type
870            ));
871        }
872        let mut entries: Vec<(msgpack::Value, msgpack::Value)> = vec![
873            (
874                msgpack::Value::UInt(INTERFACE_TYPE as u64),
875                msgpack::Value::Str(iface.config.interface_type.clone()),
876            ),
877            (
878                msgpack::Value::UInt(TRANSPORT as u64),
879                msgpack::Value::Bool(iface.transport_enabled),
880            ),
881            (
882                msgpack::Value::UInt(NAME as u64),
883                msgpack::Value::Str(iface.config.discovery_name.clone()),
884            ),
885            (
886                msgpack::Value::UInt(TRANSPORT_ID as u64),
887                msgpack::Value::Bin(transport_id.to_vec()),
888            ),
889            (
890                msgpack::Value::UInt(TRANSPORT_IMPL as u64),
891                msgpack::Value::Str(TRANSPORT_IMPLEMENTATION_NAME.into()),
892            ),
893            (
894                msgpack::Value::UInt(TRANSPORT_VERS as u64),
895                msgpack::Value::Str(TRANSPORT_IMPLEMENTATION_VERSION.into()),
896            ),
897        ];
898        if let Some(ref reachable) = iface.config.reachable_on {
899            entries.push((
900                msgpack::Value::UInt(REACHABLE_ON as u64),
901                msgpack::Value::Str(reachable.clone()),
902            ));
903        }
904        if let Some(port) = iface.config.listen_port {
905            entries.push((
906                msgpack::Value::UInt(PORT as u64),
907                msgpack::Value::UInt(port as u64),
908            ));
909        }
910        if let Some(lat) = iface.config.latitude {
911            entries.push((
912                msgpack::Value::UInt(LATITUDE as u64),
913                msgpack::Value::Float(lat),
914            ));
915        }
916        if let Some(lon) = iface.config.longitude {
917            entries.push((
918                msgpack::Value::UInt(LONGITUDE as u64),
919                msgpack::Value::Float(lon),
920            ));
921        }
922        if let Some(h) = iface.config.height {
923            entries.push((
924                msgpack::Value::UInt(HEIGHT as u64),
925                msgpack::Value::Float(h),
926            ));
927        }
928        if let Some(address) = iface.config.operator_lxmf_address {
929            entries.push((
930                msgpack::Value::UInt(OP_ADDR as u64),
931                msgpack::Value::Bin(address.to_vec()),
932            ));
933        }
934        if let Some(ref netname) = iface.ifac_netname {
935            entries.push((
936                msgpack::Value::UInt(IFAC_NETNAME as u64),
937                msgpack::Value::Str(netname.clone()),
938            ));
939        }
940        if let Some(ref netkey) = iface.ifac_netkey {
941            entries.push((
942                msgpack::Value::UInt(IFAC_NETKEY as u64),
943                msgpack::Value::Str(netkey.clone()),
944            ));
945        }
946
947        Ok(msgpack::pack(&msgpack::Value::Map(entries)))
948    }
949}
950
951// ============================================================================
952// Tests
953// ============================================================================
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use std::sync::atomic::{AtomicU64, Ordering};
959
960    fn discovery_test_dir(label: &str) -> PathBuf {
961        static COUNTER: AtomicU64 = AtomicU64::new(0);
962        std::env::temp_dir().join(format!(
963            "rns-discovery-{label}-{}-{}",
964            std::process::id(),
965            COUNTER.fetch_add(1, Ordering::Relaxed)
966        ))
967    }
968
969    fn test_announce_interface(name: &str, interval: u64) -> DiscoverableInterface {
970        DiscoverableInterface {
971            interface_name: name.to_string(),
972            config: DiscoveryConfig {
973                discovery_name: format!("Discovery {name}"),
974                announce_interval: interval,
975                stamp_value: 0,
976                reachable_on: Some("example.test".into()),
977                interface_type: "BackboneInterface".into(),
978                listen_port: Some(4242),
979                location_cmd: None,
980                latitude: Some(45.0),
981                longitude: Some(9.0),
982                height: Some(100.0),
983                operator_lxmf_address: None,
984            },
985            transport_enabled: true,
986            ifac_netname: Some("testnet".into()),
987            ifac_netkey: Some("secret".into()),
988        }
989    }
990
991    fn wait_for_announce(announcer: &mut InterfaceAnnouncer) -> AnnounceResult {
992        for _ in 0..10_000 {
993            if let Some(result) = announcer.poll_ready() {
994                return result;
995            }
996            std::thread::sleep(Duration::from_millis(1));
997        }
998        panic!("background discovery job did not complete");
999    }
1000
1001    #[test]
1002    fn location_output_parser_enforces_shape_finiteness_and_ranges() {
1003        for valid in [
1004            "-90,-180,-4000",
1005            "90,180,1000000\n",
1006            " 45.5, 9.25, 123.0\r\n",
1007        ] {
1008            assert!(parse_location_output(valid.as_bytes()).is_ok(), "{valid}");
1009        }
1010        for invalid in [
1011            "",
1012            "1,2",
1013            "1,2,3,4",
1014            "one,2,3",
1015            "NaN,2,3",
1016            "inf,2,3",
1017            "91,2,3",
1018            "1,181,3",
1019            "1,2,-4001",
1020            "1,2,1000001",
1021            "1,2,3\n4,5,6\n",
1022        ] {
1023            assert!(
1024                parse_location_output(invalid.as_bytes()).is_err(),
1025                "{invalid}"
1026            );
1027        }
1028        assert!(parse_location_output(&[0xff, 0xfe]).is_err());
1029        assert!(parse_location_output(&vec![b'0'; LOCATION_CMD_MAX_STDOUT + 1]).is_err());
1030    }
1031
1032    #[cfg(unix)]
1033    fn executable_script(body: &str) -> PathBuf {
1034        use std::os::unix::fs::PermissionsExt;
1035        use std::sync::atomic::{AtomicU64, Ordering};
1036
1037        static NEXT: AtomicU64 = AtomicU64::new(0);
1038        let path = std::env::temp_dir().join(format!(
1039            "rns-location-command-{}-{}",
1040            std::process::id(),
1041            NEXT.fetch_add(1, Ordering::Relaxed)
1042        ));
1043        fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1044        let mut permissions = fs::metadata(&path).unwrap().permissions();
1045        permissions.set_mode(0o700);
1046        fs::set_permissions(&path, permissions).unwrap();
1047        // Some overlay filesystems briefly report ETXTBSY immediately after
1048        // replacing an executable inode.
1049        std::thread::sleep(Duration::from_millis(10));
1050        path
1051    }
1052
1053    #[cfg(unix)]
1054    #[test]
1055    fn location_command_is_direct_bounded_and_overrides_static_coordinates() {
1056        use std::os::unix::fs::PermissionsExt;
1057
1058        let home = std::ffi::OsString::from("/tmp/rns-home");
1059        assert_eq!(
1060            expand_location_path("~/bin/location", Some(home)).unwrap(),
1061            PathBuf::from("/tmp/rns-home/bin/location")
1062        );
1063        assert!(expand_location_path("~/bin/location", None).is_err());
1064
1065        let success = executable_script("printf '12.5,-44.25,321\\n'");
1066        assert_eq!(
1067            resolve_location(success.to_str().unwrap()).unwrap(),
1068            (12.5, -44.25, 321.0)
1069        );
1070
1071        let mut iface = test_announce_interface("dynamic", 1);
1072        iface.config.location_cmd = Some(success.to_string_lossy().into_owned());
1073        let packed = prepare_interface_info(&[0x55; 16], &iface).unwrap();
1074        let (value, consumed) = msgpack::unpack(&packed).unwrap();
1075        assert_eq!(consumed, packed.len());
1076        let Value::Map(entries) = value else {
1077            panic!("discovery metadata was not a map")
1078        };
1079        let coordinate = |key: u8| {
1080            entries.iter().find_map(|(candidate, value)| {
1081                (*candidate == Value::UInt(key as u64)).then_some(value)
1082            })
1083        };
1084        assert_eq!(coordinate(LATITUDE), Some(&Value::Float(12.5)));
1085        assert_eq!(coordinate(LONGITUDE), Some(&Value::Float(-44.25)));
1086        assert_eq!(coordinate(HEIGHT), Some(&Value::Float(321.0)));
1087        assert_eq!(iface.config.latitude, Some(45.0));
1088        assert_eq!(iface.config.longitude, Some(9.0));
1089        assert_eq!(iface.config.height, Some(100.0));
1090
1091        let nonzero = executable_script("exit 7");
1092        assert!(resolve_location(nonzero.to_str().unwrap()).is_err());
1093        let oversized = executable_script("head -c 4097 /dev/zero");
1094        assert!(resolve_location(oversized.to_str().unwrap()).is_err());
1095        let missing = success.with_extension("missing");
1096        assert!(resolve_location(missing.to_str().unwrap()).is_err());
1097
1098        let non_executable = executable_script("printf '1,2,3'");
1099        let mut permissions = fs::metadata(&non_executable).unwrap().permissions();
1100        permissions.set_mode(0o600);
1101        fs::set_permissions(&non_executable, permissions).unwrap();
1102        assert!(resolve_location(non_executable.to_str().unwrap()).is_err());
1103
1104        for path in [success, nonzero, oversized, non_executable] {
1105            let _ = fs::remove_file(path);
1106        }
1107    }
1108
1109    #[cfg(unix)]
1110    #[test]
1111    fn failed_dynamic_location_is_suppressed_then_retried_at_normal_interval() {
1112        let failure = executable_script("exit 1");
1113        let success = executable_script("printf '1,2,3\\n'");
1114        let mut iface = test_announce_interface("recovering", 10);
1115        iface.config.location_cmd = Some(failure.to_string_lossy().into_owned());
1116        let mut announcer = InterfaceAnnouncer::new([0x77; 16], vec![iface.clone()]);
1117
1118        announcer.maybe_start(10.0);
1119        assert!(wait_for_announce(&mut announcer).app_data.is_err());
1120        announcer.maybe_start(19.0);
1121        assert!(!announcer.stamp_pending);
1122
1123        iface.config.location_cmd = Some(success.to_string_lossy().into_owned());
1124        announcer.upsert_interface(iface);
1125        announcer.maybe_start(20.0);
1126        assert!(wait_for_announce(&mut announcer).app_data.is_ok());
1127
1128        let _ = fs::remove_file(failure);
1129        let _ = fs::remove_file(success);
1130    }
1131
1132    #[cfg(unix)]
1133    #[test]
1134    fn location_command_timeout_kills_and_reaps_child() {
1135        let command = executable_script("exec sleep 30");
1136        let started = Instant::now();
1137        let error = resolve_location(command.to_str().unwrap()).unwrap_err();
1138        assert!(error.contains("timed out"));
1139        assert!(started.elapsed() < Duration::from_secs(7));
1140        let _ = fs::remove_file(command);
1141    }
1142
1143    #[cfg(unix)]
1144    #[test]
1145    fn documented_location_stanza_parses_and_example_output_resolves() {
1146        use std::os::unix::fs::PermissionsExt;
1147
1148        const STANZA: &str = r#"[interfaces]
1149  [[Mobile Backbone]]
1150    type = BackboneInterface
1151    enabled = yes
1152    interface_mode = internal
1153    listen_ip = 0.0.0.0
1154    listen_port = 4242
1155    discoverable = yes
1156    discovery_name = Mobile Backbone
1157    discovery_lxmf_address = 0123456789abcdef0123456789abcdef
1158    reachable_on = backbone.example.net
1159    location_cmd = ~/bin/reticulum-location"#;
1160        const OUTPUT: &str = "45.4642,9.1900,122.5\n";
1161        let documentation = include_str!("../../docs/interface-discovery.md");
1162        assert!(documentation.contains(STANZA));
1163        assert!(documentation.contains(OUTPUT.trim_end()));
1164
1165        let parsed = crate::config::parse(STANZA).unwrap();
1166        assert_eq!(
1167            parsed.interfaces[0]
1168                .params
1169                .get("discovery_lxmf_address")
1170                .map(String::as_str),
1171            Some("0123456789abcdef0123456789abcdef")
1172        );
1173        let command = parsed.interfaces[0].params.get("location_cmd").unwrap();
1174        let home = std::env::temp_dir().join(format!(
1175            "rns-documented-location-home-{}",
1176            std::process::id()
1177        ));
1178        let bin = home.join("bin");
1179        fs::create_dir_all(&bin).unwrap();
1180        let path = expand_location_path(command, Some(home.clone().into_os_string())).unwrap();
1181        fs::write(&path, format!("#!/bin/sh\nprintf '{OUTPUT}'\n")).unwrap();
1182        let mut permissions = fs::metadata(&path).unwrap().permissions();
1183        permissions.set_mode(0o700);
1184        fs::set_permissions(&path, permissions).unwrap();
1185        std::thread::sleep(Duration::from_millis(10));
1186
1187        assert_eq!(
1188            resolve_location(path.to_str().unwrap()).unwrap(),
1189            (45.4642, 9.19, 122.5)
1190        );
1191        let _ = fs::remove_dir_all(home);
1192    }
1193
1194    #[test]
1195    fn announcer_selects_due_interfaces_one_job_at_a_time_and_accounts_interval() {
1196        let mut announcer = InterfaceAnnouncer::new(
1197            [0x42; 16],
1198            vec![
1199                test_announce_interface("first", 10),
1200                test_announce_interface("second", 10),
1201            ],
1202        );
1203
1204        announcer.maybe_start(10.0);
1205        assert!(announcer.stamp_pending);
1206        assert_eq!(announcer.last_announced, vec![10.0, 0.0]);
1207        announcer.maybe_start(20.0);
1208        assert_eq!(announcer.last_announced, vec![10.0, 0.0]);
1209
1210        let first = wait_for_announce(&mut announcer);
1211        assert_eq!(first.interface_name, "first");
1212        assert!(first.app_data.is_ok());
1213        assert!(!announcer.stamp_pending);
1214
1215        announcer.maybe_start(20.0);
1216        let second = wait_for_announce(&mut announcer);
1217        assert_eq!(second.interface_name, "second");
1218        assert_eq!(announcer.last_announced, vec![10.0, 20.0]);
1219    }
1220
1221    #[test]
1222    fn poll_ready_clears_pending_for_failed_generation() {
1223        let mut announcer = InterfaceAnnouncer::new([0; 16], vec![]);
1224        announcer.stamp_pending = true;
1225        announcer
1226            .stamp_tx
1227            .send(AnnounceResult {
1228                interface_name: "failed".into(),
1229                app_data: Err("metadata failed".into()),
1230            })
1231            .unwrap();
1232
1233        let result = announcer.poll_ready().unwrap();
1234        assert_eq!(result.app_data.unwrap_err(), "metadata failed");
1235        assert!(!announcer.stamp_pending);
1236    }
1237
1238    #[test]
1239    fn background_packing_preserves_static_metadata_bytes() {
1240        let iface = test_announce_interface("static", 1);
1241        let expected = InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface).unwrap();
1242        let mut announcer = InterfaceAnnouncer::new([0x24; 16], vec![iface]);
1243
1244        announcer.maybe_start(1.0);
1245        let result = wait_for_announce(&mut announcer).app_data.unwrap();
1246        assert_eq!(&result[1..result.len() - STAMP_SIZE], expected.as_slice());
1247    }
1248
1249    #[test]
1250    fn announcer_packs_operator_lxmf_address() {
1251        let mut iface = test_announce_interface("operator", 1);
1252        iface.config.operator_lxmf_address = Some([0xa5; 16]);
1253
1254        let packed = InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface).unwrap();
1255        let (Value::Map(entries), consumed) = msgpack::unpack(&packed).unwrap() else {
1256            panic!("discovery metadata was not a map")
1257        };
1258
1259        assert_eq!(consumed, packed.len());
1260        assert!(entries.contains(&(Value::UInt(OP_ADDR as u64), Value::Bin(vec![0xa5; 16]),)));
1261    }
1262
1263    #[test]
1264    fn announcer_identifies_native_transport_implementation_and_version() {
1265        let iface = test_announce_interface("implementation", 1);
1266        let packed = InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface).unwrap();
1267        let (Value::Map(entries), consumed) = msgpack::unpack(&packed).unwrap() else {
1268            panic!("discovery metadata was not a map")
1269        };
1270
1271        assert_eq!(consumed, packed.len());
1272        assert!(entries.contains(&(
1273            Value::UInt(TRANSPORT_IMPL as u64),
1274            Value::Str("rns-rs".into()),
1275        )));
1276        assert!(entries.contains(&(
1277            Value::UInt(TRANSPORT_VERS as u64),
1278            Value::Str(env!("CARGO_PKG_VERSION").into()),
1279        )));
1280    }
1281
1282    #[test]
1283    fn announcer_suppresses_network_discovery_without_reachable_address() {
1284        for interface_type in ["BackboneInterface", "TCPServerInterface"] {
1285            let mut iface = test_announce_interface("missing-address", 1);
1286            iface.config.interface_type = interface_type.into();
1287            iface.config.reachable_on = None;
1288
1289            assert_eq!(
1290                InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface),
1291                Err(format!(
1292                    "{interface_type} discovery requires a reachable_on address"
1293                ))
1294            );
1295        }
1296    }
1297
1298    #[test]
1299    fn test_hex_encode() {
1300        assert_eq!(hex_encode(&[0x00, 0xff, 0x12]), "00ff12");
1301        assert_eq!(hex_encode(&[]), "");
1302    }
1303
1304    #[test]
1305    fn test_compute_discovery_hash() {
1306        let transport_id = [0x42u8; 16];
1307        let name = "TestInterface";
1308        let hash = compute_discovery_hash(&transport_id, name);
1309
1310        // Should be deterministic
1311        let hash2 = compute_discovery_hash(&transport_id, name);
1312        assert_eq!(hash, hash2);
1313
1314        // Different name should give different hash
1315        let hash3 = compute_discovery_hash(&transport_id, "OtherInterface");
1316        assert_ne!(hash, hash3);
1317    }
1318
1319    #[test]
1320    fn test_is_ip_address() {
1321        assert!(is_ip_address("192.168.1.1"));
1322        assert!(is_ip_address("::1"));
1323        assert!(is_ip_address("2001:db8::1"));
1324        assert!(!is_ip_address("not-an-ip"));
1325        assert!(!is_ip_address("hostname.example.com"));
1326    }
1327
1328    #[test]
1329    fn test_is_hostname() {
1330        assert!(is_hostname("example.com"));
1331        assert!(is_hostname("sub.example.com"));
1332        assert!(is_hostname("my-node"));
1333        assert!(is_hostname("my-node.example.com"));
1334        assert!(!is_hostname(""));
1335        assert!(!is_hostname("-invalid"));
1336        assert!(!is_hostname("invalid-"));
1337        assert!(!is_hostname("a".repeat(300).as_str()));
1338    }
1339
1340    #[test]
1341    fn test_discovered_status() {
1342        let now = time::now();
1343
1344        let mut iface = DiscoveredInterface {
1345            interface_type: "TestInterface".into(),
1346            transport: true,
1347            name: "Test".into(),
1348            discovered: now,
1349            last_heard: now,
1350            heard_count: 0,
1351            status: DiscoveredStatus::Available,
1352            stamp: vec![],
1353            stamp_value: 14,
1354            transport_id: [0u8; 16],
1355            network_id: [0u8; 16],
1356            hops: 0,
1357            latitude: None,
1358            longitude: None,
1359            height: None,
1360            operator_lxmf_address: None,
1361            reachable_on: None,
1362            port: None,
1363            frequency: None,
1364            bandwidth: None,
1365            spreading_factor: None,
1366            coding_rate: None,
1367            modulation: None,
1368            channel: None,
1369            ifac_netname: None,
1370            ifac_netkey: None,
1371            config_entry: None,
1372            discovery_hash: [0u8; 32],
1373        };
1374
1375        // Fresh interface should be available
1376        assert_eq!(iface.compute_status(), DiscoveredStatus::Available);
1377
1378        // 25 hours old should be unknown
1379        iface.last_heard = now - THRESHOLD_UNKNOWN - 3600.0;
1380        assert_eq!(iface.compute_status(), DiscoveredStatus::Unknown);
1381
1382        // 4 days old should be stale
1383        iface.last_heard = now - THRESHOLD_STALE - 3600.0;
1384        assert_eq!(iface.compute_status(), DiscoveredStatus::Stale);
1385    }
1386
1387    fn test_discovered_interface(name: &str) -> DiscoveredInterface {
1388        DiscoveredInterface {
1389            interface_type: "BackboneInterface".into(),
1390            transport: true,
1391            name: name.into(),
1392            discovered: 1700000000.0,
1393            last_heard: 1700001000.0,
1394            heard_count: 5,
1395            status: DiscoveredStatus::Available,
1396            stamp: vec![0x42u8; 64],
1397            stamp_value: 18,
1398            transport_id: [0x01u8; 16],
1399            network_id: [0x02u8; 16],
1400            hops: 2,
1401            latitude: Some(45.0),
1402            longitude: Some(9.0),
1403            height: Some(100.0),
1404            operator_lxmf_address: Some([0xa5; 16]),
1405            reachable_on: Some("example.com".into()),
1406            port: Some(4242),
1407            frequency: None,
1408            bandwidth: None,
1409            spreading_factor: None,
1410            coding_rate: None,
1411            modulation: None,
1412            channel: None,
1413            ifac_netname: Some("mynetwork".into()),
1414            ifac_netkey: Some("secretkey".into()),
1415            config_entry: Some("test config".into()),
1416            discovery_hash: compute_discovery_hash(&[0x01u8; 16], name),
1417        }
1418    }
1419
1420    #[test]
1421    fn test_storage_roundtrip() {
1422        use std::sync::atomic::{AtomicU64, Ordering};
1423        static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
1424
1425        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
1426        let dir =
1427            std::env::temp_dir().join(format!("rns-discovery-test-{}-{}", std::process::id(), id));
1428        let _ = fs::remove_dir_all(&dir);
1429        fs::create_dir_all(&dir).unwrap();
1430
1431        let storage = DiscoveredInterfaceStorage::new(dir.clone());
1432
1433        let iface = test_discovered_interface("TestNode");
1434
1435        // Store
1436        storage.store(&iface).unwrap();
1437
1438        // Load
1439        let loaded = storage.load(&iface.discovery_hash).unwrap().unwrap();
1440
1441        assert_eq!(loaded.interface_type, iface.interface_type);
1442        assert_eq!(loaded.name, iface.name);
1443        assert_eq!(loaded.stamp_value, iface.stamp_value);
1444        assert_eq!(loaded.transport_id, iface.transport_id);
1445        assert_eq!(loaded.hops, iface.hops);
1446        assert_eq!(loaded.latitude, iface.latitude);
1447        assert_eq!(loaded.operator_lxmf_address, iface.operator_lxmf_address);
1448        assert_eq!(loaded.reachable_on, iface.reachable_on);
1449        assert_eq!(loaded.port, iface.port);
1450
1451        // List
1452        let list = storage.list().unwrap();
1453        assert_eq!(list.len(), 1);
1454
1455        // Remove
1456        storage.remove(&iface.discovery_hash).unwrap();
1457        let list = storage.list().unwrap();
1458        assert!(list.is_empty());
1459
1460        let _ = fs::remove_dir_all(&dir);
1461    }
1462
1463    #[test]
1464    fn storage_load_sanitizes_cached_interface_names() {
1465        let dir = std::env::temp_dir().join(format!(
1466            "rns-discovery-sanitize-test-{}",
1467            std::process::id()
1468        ));
1469        let _ = fs::remove_dir_all(&dir);
1470        fs::create_dir_all(&dir).unwrap();
1471        let storage = DiscoveredInterfaceStorage::new(dir.clone());
1472        let iface = test_discovered_interface("\t**Cached     Name!!!\n");
1473
1474        storage.store(&iface).unwrap();
1475
1476        let loaded = storage.load(&iface.discovery_hash).unwrap().unwrap();
1477        let listed = storage.list().unwrap();
1478
1479        assert_eq!(loaded.name, "Cached Name");
1480        assert_eq!(listed[0].name, "Cached Name");
1481
1482        let _ = fs::remove_dir_all(&dir);
1483    }
1484
1485    #[test]
1486    fn storage_rejects_cached_transport_id_with_invalid_length() {
1487        let storage = DiscoveredInterfaceStorage::new(std::env::temp_dir());
1488        let iface = test_discovered_interface("BadTransportId");
1489        let mut data = storage.serialize_interface(&iface).unwrap();
1490        let (mut value, _) = msgpack::unpack(&data).unwrap();
1491        if let Value::Map(ref mut entries) = value {
1492            for (key, val) in entries {
1493                if key.as_str() == Some("transport_id") {
1494                    *val = Value::Bin(vec![0x01; 15]);
1495                }
1496            }
1497        }
1498        data = msgpack::pack(&value);
1499
1500        let err = storage.deserialize_interface(&data).unwrap_err();
1501
1502        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1503        assert!(err.to_string().contains("transport_id"));
1504    }
1505
1506    #[test]
1507    fn store_received_preserves_existing_first_seen_and_increments_heard_count() {
1508        let dir = std::env::temp_dir().join(format!(
1509            "rns-discovery-received-preserve-test-{}",
1510            std::process::id()
1511        ));
1512        let _ = fs::remove_dir_all(&dir);
1513        fs::create_dir_all(&dir).unwrap();
1514        let storage = DiscoveredInterfaceStorage::new(dir.clone());
1515
1516        let mut existing = test_discovered_interface("ExistingDiscovery");
1517        existing.discovered = 1000.0;
1518        existing.last_heard = 1100.0;
1519        existing.heard_count = 7;
1520        storage.store(&existing).unwrap();
1521
1522        let mut received = existing.clone();
1523        received.discovered = 2000.0;
1524        received.last_heard = 3000.0;
1525        received.heard_count = 0;
1526        storage.store_received(&mut received).unwrap();
1527
1528        let loaded = storage.load(&received.discovery_hash).unwrap().unwrap();
1529        assert_eq!(received.discovered, 1000.0);
1530        assert_eq!(received.last_heard, 3000.0);
1531        assert_eq!(received.heard_count, 8);
1532        assert_eq!(loaded.discovered, 1000.0);
1533        assert_eq!(loaded.last_heard, 3000.0);
1534        assert_eq!(loaded.heard_count, 8);
1535
1536        let _ = fs::remove_dir_all(&dir);
1537    }
1538
1539    #[test]
1540    fn store_received_serializes_concurrent_counter_updates() {
1541        use std::sync::{Arc, Barrier};
1542        use std::thread;
1543
1544        let dir = std::env::temp_dir().join(format!(
1545            "rns-discovery-concurrent-received-test-{}",
1546            std::process::id()
1547        ));
1548        let _ = fs::remove_dir_all(&dir);
1549        fs::create_dir_all(&dir).unwrap();
1550        let storage = Arc::new(DiscoveredInterfaceStorage::new(dir.clone()));
1551
1552        let mut existing = test_discovered_interface("ConcurrentDiscovery");
1553        existing.discovered = 1000.0;
1554        existing.last_heard = 1000.0;
1555        existing.heard_count = 0;
1556        storage.store(&existing).unwrap();
1557
1558        let threads = 16;
1559        let updates_per_thread = 25;
1560        let barrier = Arc::new(Barrier::new(threads));
1561        let mut handles = Vec::new();
1562        for thread_id in 0..threads {
1563            let storage = Arc::clone(&storage);
1564            let barrier = Arc::clone(&barrier);
1565            let template = existing.clone();
1566            handles.push(thread::spawn(move || {
1567                barrier.wait();
1568                for update in 0..updates_per_thread {
1569                    let mut received = template.clone();
1570                    received.last_heard = 2000.0 + (thread_id * updates_per_thread + update) as f64;
1571                    storage.store_received(&mut received).unwrap();
1572                }
1573            }));
1574        }
1575
1576        for handle in handles {
1577            handle.join().unwrap();
1578        }
1579
1580        let loaded = storage.load(&existing.discovery_hash).unwrap().unwrap();
1581        assert_eq!(loaded.discovered, 1000.0);
1582        assert_eq!(loaded.heard_count as usize, threads * updates_per_thread);
1583
1584        let _ = fs::remove_dir_all(&dir);
1585    }
1586
1587    #[test]
1588    fn store_received_recreates_corrupt_cache_with_received_time_as_first_seen() {
1589        let dir = std::env::temp_dir().join(format!(
1590            "rns-discovery-corrupt-recreate-test-{}",
1591            std::process::id()
1592        ));
1593        let _ = fs::remove_dir_all(&dir);
1594        fs::create_dir_all(&dir).unwrap();
1595        let storage = DiscoveredInterfaceStorage::new(dir.clone());
1596
1597        let mut received = test_discovered_interface("CorruptDiscovery");
1598        received.discovered = 1234.0;
1599        received.last_heard = 5678.0;
1600        received.heard_count = 0;
1601        let filepath = dir.join(hex_encode(&received.discovery_hash));
1602        fs::write(&filepath, b"not msgpack").unwrap();
1603
1604        storage.store_received(&mut received).unwrap();
1605
1606        let loaded = storage.load(&received.discovery_hash).unwrap().unwrap();
1607        assert_eq!(received.discovered, 5678.0);
1608        assert_eq!(received.heard_count, 1);
1609        assert_eq!(loaded.discovered, 5678.0);
1610        assert_eq!(loaded.last_heard, 5678.0);
1611        assert_eq!(loaded.heard_count, 1);
1612        assert_eq!(loaded.name, "CorruptDiscovery");
1613
1614        let _ = fs::remove_dir_all(&dir);
1615    }
1616
1617    #[test]
1618    fn cleanup_removes_discoveries_for_blackholed_network_and_transport_identities() {
1619        let dir = discovery_test_dir("blackholed-cleanup");
1620        fs::create_dir_all(&dir).unwrap();
1621        let storage = DiscoveredInterfaceStorage::new(dir.clone());
1622        let now = time::now();
1623
1624        let mut network_blocked = test_discovered_interface("NetworkBlocked");
1625        network_blocked.last_heard = now;
1626        network_blocked.network_id = [0x31; 16];
1627        let mut transport_blocked = test_discovered_interface("TransportBlocked");
1628        transport_blocked.last_heard = now;
1629        transport_blocked.transport_id = [0x42; 16];
1630        let mut retained = test_discovered_interface("Retained");
1631        retained.last_heard = now;
1632        retained.network_id = [0x53; 16];
1633        retained.transport_id = [0x64; 16];
1634        storage.store(&network_blocked).unwrap();
1635        storage.store(&transport_blocked).unwrap();
1636        storage.store(&retained).unwrap();
1637
1638        let removed = storage
1639            .cleanup_with_blackholes(|identity| identity == &[0x31; 16] || identity == &[0x42; 16])
1640            .unwrap();
1641
1642        assert_eq!(removed, 2);
1643        let listed = storage.list().unwrap();
1644        assert_eq!(listed.len(), 1);
1645        assert_eq!(listed[0].name, "Retained");
1646        let _ = fs::remove_dir_all(&dir);
1647    }
1648
1649    #[test]
1650    fn cleanup_removes_cached_discoveries_with_missing_or_empty_identities() {
1651        let dir = discovery_test_dir("invalid-identity-cleanup");
1652        fs::create_dir_all(&dir).unwrap();
1653        let storage = DiscoveredInterfaceStorage::new(dir.clone());
1654        let iface = test_discovered_interface("InvalidIdentity");
1655        let valid = storage.serialize_interface(&iface).unwrap();
1656
1657        let (mut missing_transport, _) = msgpack::unpack(&valid).unwrap();
1658        if let Value::Map(entries) = &mut missing_transport {
1659            entries.retain(|(key, _)| key.as_str() != Some("transport_id"));
1660        }
1661        fs::write(
1662            dir.join("missing-transport"),
1663            msgpack::pack(&missing_transport),
1664        )
1665        .unwrap();
1666
1667        let (mut empty_network, _) = msgpack::unpack(&valid).unwrap();
1668        if let Value::Map(entries) = &mut empty_network {
1669            for (key, value) in entries {
1670                if key.as_str() == Some("network_id") {
1671                    *value = Value::Bin(Vec::new());
1672                }
1673            }
1674        }
1675        fs::write(dir.join("empty-network"), msgpack::pack(&empty_network)).unwrap();
1676        fs::write(dir.join("corrupt-msgpack"), b"not msgpack").unwrap();
1677
1678        assert_eq!(storage.cleanup().unwrap(), 3);
1679        assert!(storage.list().unwrap().is_empty());
1680        assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
1681        let _ = fs::remove_dir_all(&dir);
1682    }
1683
1684    #[test]
1685    fn test_filter_and_sort() {
1686        let now = time::now();
1687
1688        let ifaces = vec![
1689            DiscoveredInterface {
1690                interface_type: "BackboneInterface".into(),
1691                transport: true,
1692                name: "high-value-stale".into(),
1693                discovered: now,
1694                last_heard: now - THRESHOLD_STALE - 100.0, // Stale
1695                heard_count: 0,
1696                status: DiscoveredStatus::Stale,
1697                stamp: vec![],
1698                stamp_value: 20,
1699                transport_id: [0u8; 16],
1700                network_id: [0u8; 16],
1701                hops: 0,
1702                latitude: None,
1703                longitude: None,
1704                height: None,
1705                operator_lxmf_address: None,
1706                reachable_on: None,
1707                port: None,
1708                frequency: None,
1709                bandwidth: None,
1710                spreading_factor: None,
1711                coding_rate: None,
1712                modulation: None,
1713                channel: None,
1714                ifac_netname: None,
1715                ifac_netkey: None,
1716                config_entry: None,
1717                discovery_hash: [0u8; 32],
1718            },
1719            DiscoveredInterface {
1720                interface_type: "TCPServerInterface".into(),
1721                transport: true,
1722                name: "low-value-available".into(),
1723                discovered: now,
1724                last_heard: now - 10.0, // Available
1725                heard_count: 0,
1726                status: DiscoveredStatus::Available,
1727                stamp: vec![],
1728                stamp_value: 10,
1729                transport_id: [0u8; 16],
1730                network_id: [0u8; 16],
1731                hops: 0,
1732                latitude: None,
1733                longitude: None,
1734                height: None,
1735                operator_lxmf_address: None,
1736                reachable_on: None,
1737                port: None,
1738                frequency: None,
1739                bandwidth: None,
1740                spreading_factor: None,
1741                coding_rate: None,
1742                modulation: None,
1743                channel: None,
1744                ifac_netname: None,
1745                ifac_netkey: None,
1746                config_entry: None,
1747                discovery_hash: [1u8; 32],
1748            },
1749            DiscoveredInterface {
1750                interface_type: "I2PInterface".into(),
1751                transport: false,
1752                name: "high-value-available".into(),
1753                discovered: now,
1754                last_heard: now - 10.0, // Available
1755                heard_count: 0,
1756                status: DiscoveredStatus::Available,
1757                stamp: vec![],
1758                stamp_value: 20,
1759                transport_id: [0u8; 16],
1760                network_id: [0u8; 16],
1761                hops: 0,
1762                latitude: None,
1763                longitude: None,
1764                height: None,
1765                operator_lxmf_address: None,
1766                reachable_on: None,
1767                port: None,
1768                frequency: None,
1769                bandwidth: None,
1770                spreading_factor: None,
1771                coding_rate: None,
1772                modulation: None,
1773                channel: None,
1774                ifac_netname: None,
1775                ifac_netkey: None,
1776                config_entry: None,
1777                discovery_hash: [2u8; 32],
1778            },
1779        ];
1780
1781        // Test no filter — all included, sorted by status then value
1782        let mut result = ifaces.clone();
1783        filter_and_sort_interfaces(&mut result, false, false);
1784        assert_eq!(result.len(), 3);
1785        // Available ones should come first (higher status code)
1786        assert_eq!(result[0].name, "high-value-available");
1787        assert_eq!(result[1].name, "low-value-available");
1788        assert_eq!(result[2].name, "high-value-stale");
1789
1790        // Test only_available filter
1791        let mut result = ifaces.clone();
1792        filter_and_sort_interfaces(&mut result, true, false);
1793        assert_eq!(result.len(), 2); // stale one filtered out
1794
1795        // Test only_transport filter
1796        let mut result = ifaces.clone();
1797        filter_and_sort_interfaces(&mut result, false, true);
1798        assert_eq!(result.len(), 2); // non-transport one filtered out
1799    }
1800
1801    #[test]
1802    fn test_discovery_name_hash_deterministic() {
1803        let h1 = discovery_name_hash();
1804        let h2 = discovery_name_hash();
1805        assert_eq!(h1, h2);
1806        assert_ne!(h1, [0u8; 10]); // not all zeros
1807    }
1808}