Skip to main content

rns_net/common/
discovery.rs

1//! Interface Discovery protocol — pure types and parsing logic.
2//!
3//! Contains constants, data structures, parsing, and validation functions
4//! for the interface discovery protocol. No filesystem or threading I/O.
5//!
6//! Python reference: RNS/Discovery.py
7
8use rns_core::msgpack::{self, Value};
9use rns_core::stamp::{stamp_valid, stamp_value, stamp_workblock};
10use rns_crypto::sha256::sha256;
11
12use super::time;
13
14// ============================================================================
15// Constants (matching Python Discovery.py)
16// ============================================================================
17
18/// Discovery field IDs for msgpack encoding
19pub const NAME: u8 = 0xFF;
20pub const TRANSPORT_ID: u8 = 0xFE;
21pub const INTERFACE_TYPE: u8 = 0x00;
22pub const TRANSPORT: u8 = 0x01;
23pub const REACHABLE_ON: u8 = 0x02;
24pub const LATITUDE: u8 = 0x03;
25pub const LONGITUDE: u8 = 0x04;
26pub const HEIGHT: u8 = 0x05;
27pub const PORT: u8 = 0x06;
28pub const IFAC_NETNAME: u8 = 0x07;
29pub const IFAC_NETKEY: u8 = 0x08;
30pub const FREQUENCY: u8 = 0x09;
31pub const BANDWIDTH: u8 = 0x0A;
32pub const SPREADINGFACTOR: u8 = 0x0B;
33pub const CODINGRATE: u8 = 0x0C;
34pub const MODULATION: u8 = 0x0D;
35pub const CHANNEL: u8 = 0x0E;
36
37/// App name for discovery destination
38pub const APP_NAME: &str = "rnstransport";
39
40/// Default stamp value for interface discovery
41pub const DEFAULT_STAMP_VALUE: u8 = 14;
42
43/// Workblock expand rounds for interface discovery
44pub const WORKBLOCK_EXPAND_ROUNDS: u32 = 20;
45
46/// Stamp size in bytes
47pub const STAMP_SIZE: usize = 32;
48
49/// Interface types accepted from discovery announces.
50pub const DISCOVERABLE_TYPES: [&str; 6] = [
51    "BackboneInterface",
52    "TCPServerInterface",
53    "I2PInterface",
54    "RNodeInterface",
55    "WeaveInterface",
56    "KISSInterface",
57];
58
59// Status thresholds (in seconds)
60/// 24 hours - status becomes "unknown"
61pub const THRESHOLD_UNKNOWN: f64 = 24.0 * 60.0 * 60.0;
62/// 3 days - status becomes "stale"
63pub const THRESHOLD_STALE: f64 = 3.0 * 24.0 * 60.0 * 60.0;
64/// 7 days - interface is removed
65pub const THRESHOLD_REMOVE: f64 = 7.0 * 24.0 * 60.0 * 60.0;
66
67// Status codes for sorting
68const STATUS_STALE: i32 = 0;
69const STATUS_UNKNOWN: i32 = 100;
70const STATUS_AVAILABLE: i32 = 1000;
71
72// ============================================================================
73// Per-interface discovery configuration
74// ============================================================================
75
76/// Per-interface discovery configuration parsed from config file.
77#[derive(Debug, Clone)]
78pub struct DiscoveryConfig {
79    /// Human-readable name to advertise (defaults to interface name).
80    pub discovery_name: String,
81    /// Announce interval in seconds (default 21600 = 6h, min 300 = 5min).
82    pub announce_interval: u64,
83    /// Stamp cost for discovery PoW (default 14).
84    pub stamp_value: u8,
85    /// IP/hostname this interface is reachable on.
86    pub reachable_on: Option<String>,
87    /// Interface type string (e.g. "BackboneInterface").
88    pub interface_type: String,
89    /// Listen port of the discoverable interface.
90    pub listen_port: Option<u16>,
91    /// Geographic latitude in decimal degrees.
92    pub latitude: Option<f64>,
93    /// Geographic longitude in decimal degrees.
94    pub longitude: Option<f64>,
95    /// Height/altitude in meters.
96    pub height: Option<f64>,
97}
98
99// ============================================================================
100// Data Structures
101// ============================================================================
102
103/// Status of a discovered interface
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum DiscoveredStatus {
106    Available,
107    Unknown,
108    Stale,
109}
110
111impl DiscoveredStatus {
112    /// Get numeric code for sorting (higher = better)
113    pub fn code(&self) -> i32 {
114        match self {
115            DiscoveredStatus::Available => STATUS_AVAILABLE,
116            DiscoveredStatus::Unknown => STATUS_UNKNOWN,
117            DiscoveredStatus::Stale => STATUS_STALE,
118        }
119    }
120
121    /// Convert to string
122    pub fn as_str(&self) -> &'static str {
123        match self {
124            DiscoveredStatus::Available => "available",
125            DiscoveredStatus::Unknown => "unknown",
126            DiscoveredStatus::Stale => "stale",
127        }
128    }
129}
130
131/// Information about a discovered interface
132#[derive(Debug, Clone)]
133pub struct DiscoveredInterface {
134    /// Interface type (e.g., "BackboneInterface", "TCPServerInterface", "RNodeInterface")
135    pub interface_type: String,
136    /// Whether the announcing node has transport enabled
137    pub transport: bool,
138    /// Human-readable name of the interface
139    pub name: String,
140    /// Timestamp when first discovered
141    pub discovered: f64,
142    /// Timestamp of last announcement
143    pub last_heard: f64,
144    /// Number of times heard
145    pub heard_count: u32,
146    /// Current status based on last_heard
147    pub status: DiscoveredStatus,
148    /// Raw stamp bytes
149    pub stamp: Vec<u8>,
150    /// Calculated stamp value (leading zeros)
151    pub stamp_value: u32,
152    /// Transport identity hash (truncated)
153    pub transport_id: [u8; 16],
154    /// Network identity hash (announcer)
155    pub network_id: [u8; 16],
156    /// Number of hops to reach this interface
157    pub hops: u8,
158
159    // Optional location info
160    pub latitude: Option<f64>,
161    pub longitude: Option<f64>,
162    pub height: Option<f64>,
163
164    // Connection info
165    pub reachable_on: Option<String>,
166    pub port: Option<u16>,
167
168    // RNode/RF specific
169    pub frequency: Option<u32>,
170    pub bandwidth: Option<u32>,
171    pub spreading_factor: Option<u8>,
172    pub coding_rate: Option<u8>,
173    pub modulation: Option<String>,
174    pub channel: Option<u8>,
175
176    // IFAC info
177    pub ifac_netname: Option<String>,
178    pub ifac_netkey: Option<String>,
179
180    // Auto-generated config entry
181    pub config_entry: Option<String>,
182
183    /// Hash for storage key (SHA256 of transport_id + name)
184    pub discovery_hash: [u8; 32],
185}
186
187impl DiscoveredInterface {
188    /// Compute the current status based on last_heard timestamp
189    pub fn compute_status(&self) -> DiscoveredStatus {
190        let delta = time::now() - self.last_heard;
191        if delta > THRESHOLD_STALE {
192            DiscoveredStatus::Stale
193        } else if delta > THRESHOLD_UNKNOWN {
194            DiscoveredStatus::Unknown
195        } else {
196            DiscoveredStatus::Available
197        }
198    }
199}
200
201// ============================================================================
202// Parsing and Validation
203// ============================================================================
204
205/// Parse an interface discovery announcement from app_data.
206///
207/// Returns None if:
208/// - Data is too short
209/// - Stamp is invalid
210/// - Required fields are missing
211pub fn parse_interface_announce(
212    app_data: &[u8],
213    announced_identity_hash: &[u8; 16],
214    hops: u8,
215    required_stamp_value: u8,
216) -> Option<DiscoveredInterface> {
217    // Need at least: 1 byte flags + some data + STAMP_SIZE
218    if app_data.len() <= STAMP_SIZE + 1 {
219        return None;
220    }
221
222    // Extract flags and payload
223    let flags = app_data[0];
224    let payload = &app_data[1..];
225
226    // Check encryption flag (we don't support encrypted discovery yet)
227    let encrypted = (flags & 0x02) != 0;
228    if encrypted {
229        log::debug!("Ignoring encrypted discovered interface (not supported)");
230        return None;
231    }
232
233    // Split stamp and packed info
234    let stamp = &payload[payload.len() - STAMP_SIZE..];
235    let packed = &payload[..payload.len() - STAMP_SIZE];
236
237    // Compute infohash and workblock
238    let infohash = sha256(packed);
239    let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
240
241    // Validate stamp
242    if !stamp_valid(stamp, required_stamp_value, &workblock) {
243        log::debug!("Ignoring discovered interface with invalid stamp");
244        return None;
245    }
246
247    // Calculate stamp value
248    let stamp_value = stamp_value(&workblock, stamp);
249
250    // Unpack the interface info
251    let (value, _) = msgpack::unpack(packed).ok()?;
252    let map = value.as_map()?;
253
254    // Helper to get a value from the map by integer key
255    let get_u8_val = |key: u8| -> Option<Value> {
256        for (k, v) in map {
257            if k.as_uint()? as u8 == key {
258                return Some(v.clone());
259            }
260        }
261        None
262    };
263
264    // Extract required fields
265    let interface_type = match get_u8_val(INTERFACE_TYPE)? {
266        Value::Str(value) => value,
267        _ => return None,
268    };
269    if !is_discoverable_type(&interface_type) {
270        log::debug!(
271            "Ignoring discovered interface with unsupported type '{}'",
272            interface_type
273        );
274        return None;
275    }
276
277    let transport = match get_u8_val(TRANSPORT)? {
278        Value::Bool(value) => value,
279        _ => return None,
280    };
281    let raw_name = match get_u8_val(NAME) {
282        Some(Value::Str(value)) => value,
283        Some(_) | None => String::new(),
284    };
285    let name = sanitize_discovered_name(&raw_name)
286        .unwrap_or_else(|| format!("Discovered {}", interface_type));
287
288    let transport_id_val = get_u8_val(TRANSPORT_ID)?;
289    let transport_id_bytes = transport_id_val.as_bin()?;
290    if transport_id_bytes.len() != 16 {
291        log::debug!("Ignoring discovered interface with invalid transport_id length");
292        return None;
293    }
294    let mut transport_id = [0u8; 16];
295    transport_id.copy_from_slice(transport_id_bytes);
296
297    // Extract optional fields
298    let latitude = optional_f64_field(get_u8_val(LATITUDE))?;
299    let longitude = optional_f64_field(get_u8_val(LONGITUDE))?;
300    let height = optional_f64_field(get_u8_val(HEIGHT))?;
301    let reachable_on = match get_u8_val(REACHABLE_ON) {
302        None | Some(Value::Nil) => None,
303        Some(Value::Str(value)) => Some(value),
304        Some(_) => return None,
305    };
306    if let Some(ref reachable_on) = reachable_on {
307        if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
308            log::debug!(
309                "Ignoring discovered interface with invalid reachable_on '{}'",
310                reachable_on
311            );
312            return None;
313        }
314    }
315
316    let port = get_u8_val(PORT).and_then(|v| v.as_uint().map(|n| n as u16));
317    let frequency = get_u8_val(FREQUENCY).and_then(|v| v.as_uint().map(|n| n as u32));
318    let bandwidth = get_u8_val(BANDWIDTH).and_then(|v| v.as_uint().map(|n| n as u32));
319    let spreading_factor = get_u8_val(SPREADINGFACTOR).and_then(|v| v.as_uint().map(|n| n as u8));
320    let coding_rate = get_u8_val(CODINGRATE).and_then(|v| v.as_uint().map(|n| n as u8));
321    let modulation = get_u8_val(MODULATION).and_then(|v| v.as_str().map(|s| s.to_string()));
322    let channel = get_u8_val(CHANNEL).and_then(|v| v.as_uint().map(|n| n as u8));
323    let ifac_netname = get_u8_val(IFAC_NETNAME).map(|v| discovery_value_to_string(&v));
324    let ifac_netkey = get_u8_val(IFAC_NETKEY).map(|v| discovery_value_to_string(&v));
325
326    // Compute discovery hash
327    let discovery_hash = compute_discovery_hash(&transport_id, &name);
328
329    // Generate config entry
330    let config_entry = generate_config_entry(
331        &interface_type,
332        &name,
333        &transport_id,
334        reachable_on.as_deref(),
335        port,
336        frequency,
337        bandwidth,
338        spreading_factor,
339        coding_rate,
340        modulation.as_deref(),
341        ifac_netname.as_deref(),
342        ifac_netkey.as_deref(),
343    );
344
345    let now = time::now();
346
347    Some(DiscoveredInterface {
348        interface_type,
349        transport,
350        name,
351        discovered: now,
352        last_heard: now,
353        heard_count: 0,
354        status: DiscoveredStatus::Available,
355        stamp: stamp.to_vec(),
356        stamp_value,
357        transport_id,
358        network_id: *announced_identity_hash,
359        hops,
360        latitude,
361        longitude,
362        height,
363        reachable_on,
364        port,
365        frequency,
366        bandwidth,
367        spreading_factor,
368        coding_rate,
369        modulation,
370        channel,
371        ifac_netname,
372        ifac_netkey,
373        config_entry,
374        discovery_hash,
375    })
376}
377
378/// Compute the discovery hash for storage
379pub fn compute_discovery_hash(transport_id: &[u8; 16], name: &str) -> [u8; 32] {
380    let mut material = Vec::with_capacity(16 + name.len());
381    material.extend_from_slice(transport_id);
382    material.extend_from_slice(name.as_bytes());
383    sha256(&material)
384}
385
386/// Mark a discovered transport interface config entry for gateway mode.
387pub fn apply_transport_autoconnect_mode(
388    iface: &mut DiscoveredInterface,
389    local_transport_enabled: bool,
390) {
391    if !local_transport_enabled || !iface.transport {
392        return;
393    }
394    let Some(config_entry) = iface.config_entry.as_mut() else {
395        return;
396    };
397    if config_entry
398        .lines()
399        .any(|line| line.trim_start().starts_with("interface_mode"))
400    {
401        return;
402    }
403    if let Some(pos) = config_entry.find("  enabled = yes\n") {
404        let insert_at = pos + "  enabled = yes\n".len();
405        config_entry.insert_str(insert_at, "  interface_mode = gateway\n");
406    } else {
407        config_entry.push_str("\n  interface_mode = gateway");
408    }
409}
410
411/// Generate a config entry for auto-connecting to a discovered interface
412fn generate_config_entry(
413    interface_type: &str,
414    name: &str,
415    transport_id: &[u8; 16],
416    reachable_on: Option<&str>,
417    port: Option<u16>,
418    frequency: Option<u32>,
419    bandwidth: Option<u32>,
420    spreading_factor: Option<u8>,
421    coding_rate: Option<u8>,
422    modulation: Option<&str>,
423    ifac_netname: Option<&str>,
424    ifac_netkey: Option<&str>,
425) -> Option<String> {
426    if reachable_on.is_some_and(is_ygg_ipv6) {
427        return None;
428    }
429
430    let transport_id_hex = hex_encode(transport_id);
431    let netname_str = ifac_netname
432        .map(|n| format!("\n  network_name = {}", n))
433        .unwrap_or_default();
434    let netkey_str = ifac_netkey
435        .map(|k| format!("\n  passphrase = {}", k))
436        .unwrap_or_default();
437    let identity_str = format!("\n  transport_identity = {}", transport_id_hex);
438
439    match interface_type {
440        "BackboneInterface" | "TCPServerInterface" => {
441            let reachable = reachable_on.unwrap_or("unknown");
442            let port_val = port.unwrap_or(4242);
443            Some(format!(
444                "[[{}]]\n  type = BackboneInterface\n  enabled = yes\n  remote = {}\n  target_port = {}{}{}{}",
445                name, reachable, port_val, identity_str, netname_str, netkey_str
446            ))
447        }
448        "I2PInterface" => {
449            let reachable = reachable_on.unwrap_or("unknown");
450            Some(format!(
451                "[[{}]]\n  type = I2PInterface\n  enabled = yes\n  peers = {}{}{}{}",
452                name, reachable, identity_str, netname_str, netkey_str
453            ))
454        }
455        "RNodeInterface" => {
456            let freq_str = frequency
457                .map(|f| format!("\n  frequency = {}", f))
458                .unwrap_or_default();
459            let bw_str = bandwidth
460                .map(|b| format!("\n  bandwidth = {}", b))
461                .unwrap_or_default();
462            let sf_str = spreading_factor
463                .map(|s| format!("\n  spreadingfactor = {}", s))
464                .unwrap_or_default();
465            let cr_str = coding_rate
466                .map(|c| format!("\n  codingrate = {}", c))
467                .unwrap_or_default();
468            Some(format!(
469                "[[{}]]\n  type = RNodeInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}{}",
470                name, "", freq_str, bw_str, sf_str, cr_str, identity_str, netname_str, netkey_str
471            ))
472        }
473        "KISSInterface" => {
474            let freq_str = frequency
475                .map(|f| format!("\n  # Frequency: {}", f))
476                .unwrap_or_default();
477            let bw_str = bandwidth
478                .map(|b| format!("\n  # Bandwidth: {}", b))
479                .unwrap_or_default();
480            let mod_str = modulation
481                .map(|m| format!("\n  # Modulation: {}", m))
482                .unwrap_or_default();
483            Some(format!(
484                "[[{}]]\n  type = KISSInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}",
485                name, "", freq_str, bw_str, mod_str, identity_str, netname_str, netkey_str
486            ))
487        }
488        "WeaveInterface" => Some(format!(
489            "[[{}]]\n  type = WeaveInterface\n  enabled = yes\n  port = {}{}{}{}",
490            name, "", identity_str, netname_str, netkey_str
491        )),
492        _ => None,
493    }
494}
495
496// ============================================================================
497// Helper Functions
498// ============================================================================
499
500fn optional_f64_field(value: Option<Value>) -> Option<Option<f64>> {
501    match value {
502        None | Some(Value::Nil) => Some(None),
503        Some(Value::Float(value)) if value.is_finite() => Some(Some(value)),
504        Some(_) => None,
505    }
506}
507
508fn discovery_value_to_string(value: &Value) -> String {
509    match value {
510        Value::Nil => "None".to_string(),
511        Value::Bool(value) => value.to_string(),
512        Value::UInt(value) => value.to_string(),
513        Value::Int(value) => value.to_string(),
514        Value::Float(value) => value.to_string(),
515        Value::Bin(value) => hex_encode(value),
516        Value::Str(value) => value.clone(),
517        Value::Array(_) => "[]".to_string(),
518        Value::Map(_) => "{}".to_string(),
519    }
520}
521
522/// Sanitize a discovered interface name like upstream Reticulum.
523pub fn sanitize_discovered_name(name: &str) -> Option<String> {
524    let ascii: String = name.chars().filter(|ch| ch.is_ascii()).collect();
525    let mut sanitized = ascii.trim().to_string();
526    while sanitized.contains("  ") {
527        sanitized = sanitized.replace("  ", " ");
528    }
529
530    let start = sanitized
531        .char_indices()
532        .find(|(_, ch)| ch.is_ascii_alphanumeric())
533        .map(|(idx, _)| idx)?;
534    let end = sanitized
535        .char_indices()
536        .rev()
537        .find(|(_, ch)| ch.is_ascii_alphanumeric() || *ch == ')')
538        .map(|(idx, ch)| idx + ch.len_utf8())?;
539
540    if start >= end {
541        return None;
542    }
543
544    let sanitized = sanitized[start..end].to_string();
545    (!sanitized.is_empty()).then_some(sanitized)
546}
547
548/// Encode bytes as hex string (no delimiters)
549pub fn hex_encode(bytes: &[u8]) -> String {
550    bytes.iter().map(|b| format!("{:02x}", b)).collect()
551}
552
553/// Check if a string is a valid IP address
554pub fn is_ip_address(s: &str) -> bool {
555    s.parse::<std::net::IpAddr>().is_ok()
556}
557
558/// Check if an address belongs to Yggdrasil's `200::/7` IPv6 range.
559pub fn is_ygg_ipv6(s: &str) -> bool {
560    match s.parse::<std::net::IpAddr>() {
561        Ok(std::net::IpAddr::V6(addr)) => {
562            let segments = addr.segments();
563            (segments[0] & 0xfe00) == 0x0200
564        }
565        _ => false,
566    }
567}
568
569/// Check if a string is a valid hostname
570pub fn is_hostname(s: &str) -> bool {
571    let s = s.strip_suffix('.').unwrap_or(s);
572    if s.len() > 253 {
573        return false;
574    }
575    let components: Vec<&str> = s.split('.').collect();
576    if components.is_empty() {
577        return false;
578    }
579    // Last component should not be all numeric
580    if components
581        .last()
582        .map(|c| c.chars().all(|ch| ch.is_ascii_digit()))
583        .unwrap_or(false)
584    {
585        return false;
586    }
587    components.iter().all(|c| {
588        !c.is_empty()
589            && c.len() <= 63
590            && !c.starts_with('-')
591            && !c.ends_with('-')
592            && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
593    })
594}
595
596/// Check whether an interface type can be accepted from discovery.
597pub fn is_discoverable_type(interface_type: &str) -> bool {
598    DISCOVERABLE_TYPES.contains(&interface_type)
599}
600
601/// Filter and sort discovered interfaces
602pub fn filter_and_sort_interfaces(
603    interfaces: &mut Vec<DiscoveredInterface>,
604    only_available: bool,
605    only_transport: bool,
606) {
607    let now = time::now();
608
609    // Update status and filter
610    interfaces.retain(|iface| {
611        if !is_discoverable_type(&iface.interface_type) {
612            return false;
613        }
614        if let Some(ref reachable_on) = iface.reachable_on {
615            if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
616                return false;
617            }
618        }
619
620        let delta = now - iface.last_heard;
621
622        // Check for removal threshold
623        if delta > THRESHOLD_REMOVE {
624            return false;
625        }
626
627        // Update status
628        let status = iface.compute_status();
629
630        // Apply filters
631        if only_available && status != DiscoveredStatus::Available {
632            return false;
633        }
634        if only_transport && !iface.transport {
635            return false;
636        }
637
638        true
639    });
640
641    // Sort by (status_code desc, value desc, last_heard desc)
642    interfaces.sort_by(|a, b| {
643        let status_cmp = b.compute_status().code().cmp(&a.compute_status().code());
644        if status_cmp != std::cmp::Ordering::Equal {
645            return status_cmp;
646        }
647        let value_cmp = b.stamp_value.cmp(&a.stamp_value);
648        if value_cmp != std::cmp::Ordering::Equal {
649            return value_cmp;
650        }
651        b.last_heard
652            .partial_cmp(&a.last_heard)
653            .unwrap_or(std::cmp::Ordering::Equal)
654    });
655}
656
657/// Compute the name hash for the discovery destination: `rnstransport.discovery.interface`.
658///
659/// Discovery is a SINGLE destination — its dest hash varies with the sender's identity.
660/// We match incoming announces by comparing their name_hash to this constant.
661pub fn discovery_name_hash() -> [u8; 10] {
662    rns_core::destination::name_hash(APP_NAME, &["discovery", "interface"])
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    fn pack_discovery_entries(entries: Vec<(Value, Value)>) -> Vec<u8> {
670        let packed = msgpack::pack(&Value::Map(entries));
671        let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
672        app_data.push(0x00);
673        app_data.extend_from_slice(&packed);
674        app_data.extend_from_slice(&[0u8; STAMP_SIZE]);
675        app_data
676    }
677
678    fn discovery_entries(interface_type: &str, reachable_on: Option<&str>) -> Vec<(Value, Value)> {
679        let mut entries = vec![
680            (
681                Value::UInt(INTERFACE_TYPE as u64),
682                Value::Str(interface_type.to_string()),
683            ),
684            (Value::UInt(TRANSPORT as u64), Value::Bool(true)),
685            (
686                Value::UInt(NAME as u64),
687                Value::Str(format!("test-{interface_type}")),
688            ),
689            (Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 16])),
690        ];
691
692        if let Some(reachable_on) = reachable_on {
693            entries.push((
694                Value::UInt(REACHABLE_ON as u64),
695                Value::Str(reachable_on.to_string()),
696            ));
697        }
698
699        entries
700    }
701
702    fn build_discovery_app_data(interface_type: &str, reachable_on: Option<&str>) -> Vec<u8> {
703        pack_discovery_entries(discovery_entries(interface_type, reachable_on))
704    }
705
706    #[test]
707    fn parse_rejects_unsupported_discovered_interface_type() {
708        let app_data = build_discovery_app_data("BogusInterface", None);
709
710        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
711
712        assert!(
713            parsed.is_none(),
714            "unsupported discovered interface types must be ignored"
715        );
716    }
717
718    #[test]
719    fn parse_rejects_invalid_reachable_on_address() {
720        let app_data = build_discovery_app_data("BackboneInterface", Some("-not a host-"));
721
722        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
723
724        assert!(
725            parsed.is_none(),
726            "discovered interfaces with invalid reachable_on values must be ignored"
727        );
728    }
729
730    #[test]
731    fn parse_sanitizes_discovered_interface_name() {
732        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
733        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
734        entries.push((
735            Value::UInt(NAME as u64),
736            Value::Str("\t**Alpha     Beta!!!\n".to_string()),
737        ));
738        let app_data = pack_discovery_entries(entries);
739
740        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
741
742        assert_eq!(parsed.name, "Alpha Beta");
743        assert!(parsed.config_entry.unwrap().starts_with("[[Alpha Beta]]"));
744    }
745
746    #[test]
747    fn parse_falls_back_when_discovered_interface_name_sanitizes_empty() {
748        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
749        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
750        entries.push((Value::UInt(NAME as u64), Value::Str("!!!".to_string())));
751        let app_data = pack_discovery_entries(entries);
752
753        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
754
755        assert_eq!(parsed.name, "Discovered BackboneInterface");
756    }
757
758    #[test]
759    fn parse_rejects_invalid_discovered_interface_field_types() {
760        for (field, replacement) in [
761            (TRANSPORT, Value::Str("yes".to_string())),
762            (LATITUDE, Value::Str("45.0".to_string())),
763            (LONGITUDE, Value::Str("9.0".to_string())),
764            (HEIGHT, Value::Str("100".to_string())),
765            (INTERFACE_TYPE, Value::UInt(123)),
766            (REACHABLE_ON, Value::UInt(123)),
767        ] {
768            let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
769            entries.retain(|(key, _)| key.as_uint() != Some(field as u64));
770            entries.push((Value::UInt(field as u64), replacement));
771            let app_data = pack_discovery_entries(entries);
772
773            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
774
775            assert!(parsed.is_none(), "field {field} should reject invalid type");
776        }
777    }
778
779    #[test]
780    fn parse_rejects_invalid_transport_id_length() {
781        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
782        entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT_ID as u64));
783        entries.push((Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 15])));
784        let app_data = pack_discovery_entries(entries);
785
786        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
787
788        assert!(parsed.is_none());
789    }
790
791    #[test]
792    fn parse_converts_ifac_fields_to_strings() {
793        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
794        entries.push((Value::UInt(IFAC_NETNAME as u64), Value::UInt(123)));
795        entries.push((Value::UInt(IFAC_NETKEY as u64), Value::Bool(true)));
796        let app_data = pack_discovery_entries(entries);
797
798        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
799
800        assert_eq!(parsed.ifac_netname.as_deref(), Some("123"));
801        assert_eq!(parsed.ifac_netkey.as_deref(), Some("true"));
802    }
803
804    #[test]
805    fn transport_autoconnect_mode_marks_transport_discovery_config_as_gateway() {
806        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
807        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
808
809        apply_transport_autoconnect_mode(&mut parsed, true);
810
811        let config_entry = parsed.config_entry.unwrap();
812        assert!(config_entry.contains("  enabled = yes\n  interface_mode = gateway\n"));
813    }
814
815    #[test]
816    fn transport_autoconnect_mode_does_not_modify_non_transport_contexts() {
817        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
818        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
819        let original = parsed.config_entry.clone();
820
821        apply_transport_autoconnect_mode(&mut parsed, false);
822
823        assert_eq!(parsed.config_entry, original);
824    }
825
826    #[test]
827    fn transport_autoconnect_mode_does_not_modify_non_transport_announces() {
828        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
829        entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT as u64));
830        entries.push((Value::UInt(TRANSPORT as u64), Value::Bool(false)));
831        let app_data = pack_discovery_entries(entries);
832        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
833        let original = parsed.config_entry.clone();
834
835        apply_transport_autoconnect_mode(&mut parsed, true);
836
837        assert_eq!(parsed.config_entry, original);
838    }
839
840    #[test]
841    fn transport_autoconnect_mode_preserves_existing_interface_mode() {
842        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
843        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
844        let config_entry = parsed.config_entry.as_mut().unwrap();
845        config_entry.push_str("\n  interface_mode = access_point");
846        let original = parsed.config_entry.clone();
847
848        apply_transport_autoconnect_mode(&mut parsed, true);
849
850        assert_eq!(parsed.config_entry, original);
851    }
852
853    #[test]
854    fn parse_yggdrasil_reachable_on_keeps_record_without_config_entry() {
855        let app_data = build_discovery_app_data("BackboneInterface", Some("200:1234::1"));
856
857        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
858
859        assert_eq!(parsed.reachable_on.as_deref(), Some("200:1234::1"));
860        assert!(parsed.config_entry.is_none());
861    }
862
863    #[test]
864    fn parse_accepts_supported_discovered_interface_types() {
865        for interface_type in [
866            "BackboneInterface",
867            "TCPServerInterface",
868            "I2PInterface",
869            "RNodeInterface",
870            "WeaveInterface",
871            "KISSInterface",
872        ] {
873            let app_data = build_discovery_app_data(interface_type, Some("example.com"));
874
875            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
876
877            assert!(
878                parsed.is_some(),
879                "{interface_type} should be accepted as a discoverable interface type"
880            );
881        }
882    }
883}