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 std::collections::{BTreeMap, BTreeSet, VecDeque};
9
10use rns_core::msgpack::{self, Value};
11use rns_core::stamp::{stamp_valid, stamp_value, stamp_workblock};
12use rns_crypto::sha256::sha256;
13
14use super::time;
15
16// ============================================================================
17// Constants (matching Python Discovery.py)
18// ============================================================================
19
20/// Discovery field IDs for msgpack encoding
21pub const NAME: u8 = 0xFF;
22pub const TRANSPORT_ID: u8 = 0xFE;
23pub const TRANSPORT_IMPL: u8 = 0xFD;
24pub const TRANSPORT_VERS: u8 = 0xFC;
25pub const INTERFACE_TYPE: u8 = 0x00;
26pub const TRANSPORT: u8 = 0x01;
27pub const REACHABLE_ON: u8 = 0x02;
28pub const LATITUDE: u8 = 0x03;
29pub const LONGITUDE: u8 = 0x04;
30pub const HEIGHT: u8 = 0x05;
31pub const PORT: u8 = 0x06;
32pub const IFAC_NETNAME: u8 = 0x07;
33pub const IFAC_NETKEY: u8 = 0x08;
34pub const FREQUENCY: u8 = 0x09;
35pub const BANDWIDTH: u8 = 0x0A;
36pub const SPREADINGFACTOR: u8 = 0x0B;
37pub const CODINGRATE: u8 = 0x0C;
38pub const MODULATION: u8 = 0x0D;
39pub const CHANNEL: u8 = 0x0E;
40pub const OP_ADDR: u8 = 0xF0;
41
42/// Short, implementation-specific identity advertised by this transport.
43pub const TRANSPORT_IMPLEMENTATION_NAME: &str = "rns-rs";
44
45/// Version of the native transport implementation in this build.
46pub const TRANSPORT_IMPLEMENTATION_VERSION: &str = env!("CARGO_PKG_VERSION");
47
48/// App name for discovery destination
49pub const APP_NAME: &str = "rnstransport";
50
51/// Default stamp value for interface discovery
52pub const DEFAULT_STAMP_VALUE: u8 = 16;
53
54/// Workblock expand rounds for interface discovery
55pub const WORKBLOCK_EXPAND_ROUNDS: u32 = 20;
56
57/// Stamp size in bytes
58pub const STAMP_SIZE: usize = 32;
59
60/// Maximum number of known-invalid discovery payloads retained by default.
61pub const DISCOVERY_STAMP_CACHE_MAX_ENTRIES: usize = 2048;
62
63/// Interface types accepted from discovery announces.
64pub const DISCOVERABLE_TYPES: [&str; 6] = [
65    "BackboneInterface",
66    "TCPServerInterface",
67    "I2PInterface",
68    "RNodeInterface",
69    "WeaveInterface",
70    "KISSInterface",
71];
72
73// Status thresholds (in seconds)
74/// 24 hours - status becomes "unknown"
75pub const THRESHOLD_UNKNOWN: f64 = 24.0 * 60.0 * 60.0;
76/// 3 days - status becomes "stale"
77pub const THRESHOLD_STALE: f64 = 3.0 * 24.0 * 60.0 * 60.0;
78/// 7 days - interface is removed
79pub const THRESHOLD_REMOVE: f64 = 7.0 * 24.0 * 60.0 * 60.0;
80
81// Status codes for sorting
82const STATUS_STALE: i32 = 0;
83const STATUS_UNKNOWN: i32 = 100;
84const STATUS_AVAILABLE: i32 = 1000;
85
86// ============================================================================
87// Per-interface discovery configuration
88// ============================================================================
89
90/// Per-interface discovery configuration parsed from config file.
91#[derive(Debug, Clone)]
92pub struct DiscoveryConfig {
93    /// Human-readable name to advertise (defaults to interface name).
94    pub discovery_name: String,
95    /// Announce interval in seconds (default 21600 = 6h, min 300 = 5min).
96    pub announce_interval: u64,
97    /// Stamp cost for discovery PoW (default 16).
98    pub stamp_value: u8,
99    /// IP/hostname this interface is reachable on.
100    pub reachable_on: Option<String>,
101    /// Interface type string (e.g. "BackboneInterface").
102    pub interface_type: String,
103    /// Listen port of the discoverable interface.
104    pub listen_port: Option<u16>,
105    /// Executable that resolves `latitude,longitude,height` for each announce.
106    pub location_cmd: Option<String>,
107    /// Geographic latitude in decimal degrees.
108    pub latitude: Option<f64>,
109    /// Geographic longitude in decimal degrees.
110    pub longitude: Option<f64>,
111    /// Height/altitude in meters.
112    pub height: Option<f64>,
113    /// Optional 16-byte LXMF destination hash for the interface operator.
114    pub operator_lxmf_address: Option<[u8; 16]>,
115}
116
117// ============================================================================
118// Data Structures
119// ============================================================================
120
121/// Status of a discovered interface
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum DiscoveredStatus {
124    Available,
125    Unknown,
126    Stale,
127}
128
129impl DiscoveredStatus {
130    /// Get numeric code for sorting (higher = better)
131    pub fn code(&self) -> i32 {
132        match self {
133            DiscoveredStatus::Available => STATUS_AVAILABLE,
134            DiscoveredStatus::Unknown => STATUS_UNKNOWN,
135            DiscoveredStatus::Stale => STATUS_STALE,
136        }
137    }
138
139    /// Convert to string
140    pub fn as_str(&self) -> &'static str {
141        match self {
142            DiscoveredStatus::Available => "available",
143            DiscoveredStatus::Unknown => "unknown",
144            DiscoveredStatus::Stale => "stale",
145        }
146    }
147}
148
149/// Information about a discovered interface
150#[derive(Debug, Clone)]
151pub struct DiscoveredInterface {
152    /// Interface type (e.g., "BackboneInterface", "TCPServerInterface", "RNodeInterface")
153    pub interface_type: String,
154    /// Whether the announcing node has transport enabled
155    pub transport: bool,
156    /// Human-readable name of the interface
157    pub name: String,
158    /// Timestamp when first discovered
159    pub discovered: f64,
160    /// Timestamp of last announcement
161    pub last_heard: f64,
162    /// Number of times heard
163    pub heard_count: u32,
164    /// Current status based on last_heard
165    pub status: DiscoveredStatus,
166    /// Raw stamp bytes
167    pub stamp: Vec<u8>,
168    /// Calculated stamp value (leading zeros)
169    pub stamp_value: u32,
170    /// Transport identity hash (truncated)
171    pub transport_id: [u8; 16],
172    /// Network identity hash (announcer)
173    pub network_id: [u8; 16],
174    /// Number of hops to reach this interface
175    pub hops: u8,
176
177    // Optional location info
178    pub latitude: Option<f64>,
179    pub longitude: Option<f64>,
180    pub height: Option<f64>,
181    /// Optional 16-byte LXMF destination hash advertised by the operator.
182    pub operator_lxmf_address: Option<[u8; 16]>,
183
184    // Connection info
185    pub reachable_on: Option<String>,
186    pub port: Option<u16>,
187
188    // RNode/RF specific
189    pub frequency: Option<u32>,
190    pub bandwidth: Option<u32>,
191    pub spreading_factor: Option<u8>,
192    pub coding_rate: Option<u8>,
193    pub modulation: Option<String>,
194    pub channel: Option<u8>,
195
196    // IFAC info
197    pub ifac_netname: Option<String>,
198    pub ifac_netkey: Option<String>,
199
200    // Auto-generated config entry
201    pub config_entry: Option<String>,
202
203    /// Hash for storage key (SHA256 of transport_id + name)
204    pub discovery_hash: [u8; 32],
205}
206
207fn insufficient_stamp_diagnostic(value: u32) -> String {
208    format!("Ignored discovered interface with insufficient stamp value {value}")
209}
210
211/// Bounded FIFO cache for discovery payloads with invalid proof-of-work stamps.
212///
213/// The key is the full SHA-256 hash of the discovery payload after its flags
214/// byte, matching upstream Reticulum. It therefore includes the stamp itself,
215/// while harmless changes to the flags byte do not bypass the cache.
216#[derive(Debug, Clone)]
217pub struct DiscoveryStampCache {
218    capacity: usize,
219    valid_order: VecDeque<[u8; 32]>,
220    valid_entries: BTreeMap<[u8; 32], ValidStampEntry>,
221    invalid_order: VecDeque<[u8; 32]>,
222    invalid_entries: BTreeSet<[u8; 32]>,
223}
224
225#[derive(Debug, Clone)]
226struct ValidStampEntry {
227    stamp: Vec<u8>,
228    packed: Vec<u8>,
229    value: u32,
230}
231
232impl Default for DiscoveryStampCache {
233    fn default() -> Self {
234        Self::with_capacity(DISCOVERY_STAMP_CACHE_MAX_ENTRIES)
235    }
236}
237
238impl DiscoveryStampCache {
239    pub fn with_capacity(capacity: usize) -> Self {
240        Self {
241            capacity,
242            valid_order: VecDeque::with_capacity(capacity),
243            valid_entries: BTreeMap::new(),
244            invalid_order: VecDeque::with_capacity(capacity),
245            invalid_entries: BTreeSet::new(),
246        }
247    }
248
249    fn payload_hash(app_data: &[u8]) -> Option<[u8; 32]> {
250        app_data
251            .get(1..)
252            .filter(|payload| !payload.is_empty())
253            .map(sha256)
254    }
255
256    fn contains_invalid_hash(&self, payload_hash: &[u8; 32]) -> bool {
257        self.invalid_entries.contains(payload_hash)
258    }
259
260    fn valid_entry(&self, payload_hash: &[u8; 32]) -> Option<&ValidStampEntry> {
261        self.valid_entries.get(payload_hash)
262    }
263
264    fn insert_valid_hash(&mut self, payload_hash: [u8; 32], entry: ValidStampEntry) {
265        if self.capacity == 0 || self.valid_entries.contains_key(&payload_hash) {
266            return;
267        }
268        if self.valid_order.len() == self.capacity {
269            if let Some(evicted) = self.valid_order.pop_front() {
270                self.valid_entries.remove(&evicted);
271            }
272        }
273        self.valid_order.push_back(payload_hash);
274        self.valid_entries.insert(payload_hash, entry);
275    }
276
277    fn insert_invalid_hash(&mut self, payload_hash: [u8; 32]) {
278        if self.capacity == 0 || self.invalid_entries.contains(&payload_hash) {
279            return;
280        }
281        if self.invalid_order.len() == self.capacity {
282            if let Some(evicted) = self.invalid_order.pop_front() {
283                self.invalid_entries.remove(&evicted);
284            }
285        }
286        self.invalid_order.push_back(payload_hash);
287        self.invalid_entries.insert(payload_hash);
288    }
289
290    pub fn invalid_len(&self) -> usize {
291        self.invalid_order.len()
292    }
293
294    pub fn valid_len(&self) -> usize {
295        self.valid_order.len()
296    }
297
298    pub fn contains_invalid_app_data(&self, app_data: &[u8]) -> bool {
299        Self::payload_hash(app_data)
300            .is_some_and(|payload_hash| self.contains_invalid_hash(&payload_hash))
301    }
302
303    pub fn contains_valid_app_data(&self, app_data: &[u8]) -> bool {
304        Self::payload_hash(app_data)
305            .is_some_and(|payload_hash| self.valid_entries.contains_key(&payload_hash))
306    }
307}
308
309impl DiscoveredInterface {
310    /// Compute the current status based on last_heard timestamp
311    pub fn compute_status(&self) -> DiscoveredStatus {
312        let delta = time::now() - self.last_heard;
313        if delta > THRESHOLD_STALE {
314            DiscoveredStatus::Stale
315        } else if delta > THRESHOLD_UNKNOWN {
316            DiscoveredStatus::Unknown
317        } else {
318            DiscoveredStatus::Available
319        }
320    }
321}
322
323// ============================================================================
324// Parsing and Validation
325// ============================================================================
326
327/// Parse an interface discovery announcement from app_data.
328///
329/// Returns None if:
330/// - Data is too short
331/// - Stamp is invalid
332/// - Required fields are missing
333pub fn parse_interface_announce(
334    app_data: &[u8],
335    announced_identity_hash: &[u8; 16],
336    hops: u8,
337    required_stamp_value: u8,
338) -> Option<DiscoveredInterface> {
339    let mut cache = DiscoveryStampCache::with_capacity(0);
340    parse_interface_announce_with_cache(
341        app_data,
342        announced_identity_hash,
343        hops,
344        required_stamp_value,
345        &mut cache,
346    )
347}
348
349/// Parse an interface discovery announcement while retaining invalid stamp
350/// results across calls.
351pub fn parse_interface_announce_with_cache(
352    app_data: &[u8],
353    announced_identity_hash: &[u8; 16],
354    hops: u8,
355    required_stamp_value: u8,
356    cache: &mut DiscoveryStampCache,
357) -> Option<DiscoveredInterface> {
358    // Need at least: 1 byte flags + some data + STAMP_SIZE
359    if app_data.len() <= STAMP_SIZE + 1 {
360        return None;
361    }
362
363    // Extract flags and payload
364    let flags = app_data[0];
365    let payload = &app_data[1..];
366    let payload_hash = sha256(payload);
367
368    if cache.contains_invalid_hash(&payload_hash) {
369        log::trace!(
370            target: rns_core::logging::PATHING_LOG_TARGET,
371            "Ignored previously discovered interface with insufficient stamp value"
372        );
373        return None;
374    }
375
376    // Check encryption flag (we don't support encrypted discovery yet)
377    let encrypted = (flags & 0x02) != 0;
378    if encrypted {
379        log::debug!("Ignoring encrypted discovered interface (not supported)");
380        return None;
381    }
382
383    let (stamp, packed, stamp_value) =
384        if let Some(entry) = cache.valid_entry(&payload_hash).cloned() {
385            log::trace!(
386                target: rns_core::logging::PATHING_LOG_TARGET,
387                "Discovery announce cache hit"
388            );
389            if entry.value < required_stamp_value as u32 {
390                log::debug!("{}", insufficient_stamp_diagnostic(entry.value));
391                return None;
392            }
393            (entry.stamp, entry.packed, entry.value)
394        } else {
395            // The driver owns this cache and processes events sequentially, so the
396            // upstream validation lock is implicit in Rust's ownership model.
397            let stamp = payload[payload.len() - STAMP_SIZE..].to_vec();
398            let packed = payload[..payload.len() - STAMP_SIZE].to_vec();
399            let infohash = sha256(&packed);
400            let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
401            let value = stamp_value(&workblock, &stamp);
402
403            if !stamp_valid(&stamp, required_stamp_value, &workblock) {
404                log::debug!("{}", insufficient_stamp_diagnostic(value));
405                cache.insert_invalid_hash(payload_hash);
406                return None;
407            }
408            cache.insert_valid_hash(
409                payload_hash,
410                ValidStampEntry {
411                    stamp: stamp.clone(),
412                    packed: packed.clone(),
413                    value,
414                },
415            );
416            (stamp, packed, value)
417        };
418
419    // Unpack the interface info
420    let (value, _) = msgpack::unpack(&packed).ok()?;
421    let map = value.as_map()?;
422
423    // Helper to get a value from the map by integer key
424    let get_u8_val = |key: u8| -> Option<Value> {
425        for (k, v) in map {
426            if k.as_uint()? as u8 == key {
427                return Some(v.clone());
428            }
429        }
430        None
431    };
432
433    // Extract required fields
434    let interface_type = match get_u8_val(INTERFACE_TYPE)? {
435        Value::Str(value) => value,
436        _ => return None,
437    };
438    if !is_discoverable_type(&interface_type) {
439        log::debug!(
440            "Ignoring discovered interface with unsupported type '{}'",
441            interface_type
442        );
443        return None;
444    }
445
446    let transport = match get_u8_val(TRANSPORT)? {
447        Value::Bool(value) => value,
448        _ => return None,
449    };
450    let raw_name = match get_u8_val(NAME) {
451        Some(Value::Str(value)) => value,
452        Some(_) | None => String::new(),
453    };
454    let name = sanitize_discovered_name(&raw_name)
455        .unwrap_or_else(|| format!("Discovered {}", interface_type));
456
457    let transport_id_val = get_u8_val(TRANSPORT_ID)?;
458    let transport_id_bytes = transport_id_val.as_bin()?;
459    if transport_id_bytes.len() != 16 {
460        log::debug!("Ignoring discovered interface with invalid transport_id length");
461        return None;
462    }
463    let mut transport_id = [0u8; 16];
464    transport_id.copy_from_slice(transport_id_bytes);
465
466    // Extract optional fields
467    let latitude = optional_f64_field(get_u8_val(LATITUDE))?;
468    let longitude = optional_f64_field(get_u8_val(LONGITUDE))?;
469    let height = optional_f64_field(get_u8_val(HEIGHT))?;
470    let reachable_on = match get_u8_val(REACHABLE_ON) {
471        None | Some(Value::Nil) => None,
472        Some(Value::Str(value)) => Some(value),
473        Some(_) => return None,
474    };
475    if let Some(ref reachable_on) = reachable_on {
476        if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
477            log::debug!(
478                "Ignoring discovered interface with invalid reachable_on '{}'",
479                reachable_on
480            );
481            return None;
482        }
483    }
484
485    let port = get_u8_val(PORT).and_then(|v| v.as_uint().map(|n| n as u16));
486    let frequency = get_u8_val(FREQUENCY).and_then(|v| v.as_uint().map(|n| n as u32));
487    let bandwidth = get_u8_val(BANDWIDTH).and_then(|v| v.as_uint().map(|n| n as u32));
488    let spreading_factor = get_u8_val(SPREADINGFACTOR).and_then(|v| v.as_uint().map(|n| n as u8));
489    let coding_rate = get_u8_val(CODINGRATE).and_then(|v| v.as_uint().map(|n| n as u8));
490    let modulation = get_u8_val(MODULATION).and_then(|v| v.as_str().map(|s| s.to_string()));
491    let channel = get_u8_val(CHANNEL).and_then(|v| v.as_uint().map(|n| n as u8));
492    let ifac_netname = get_u8_val(IFAC_NETNAME).map(|v| discovery_value_to_string(&v));
493    let ifac_netkey = get_u8_val(IFAC_NETKEY).map(|v| discovery_value_to_string(&v));
494    let operator_lxmf_address = match get_u8_val(OP_ADDR) {
495        None | Some(Value::Nil) => None,
496        Some(Value::Bin(value)) if value.len() == 16 => {
497            let mut address = [0u8; 16];
498            address.copy_from_slice(&value);
499            Some(address)
500        }
501        Some(Value::Bin(_)) => None,
502        Some(_) => return None,
503    };
504
505    // Compute discovery hash
506    let discovery_hash = compute_discovery_hash(&transport_id, &name);
507
508    // Generate config entry
509    let config_entry = generate_config_entry(ConfigEntryParams {
510        interface_type: &interface_type,
511        name: &name,
512        transport_id: &transport_id,
513        reachable_on: reachable_on.as_deref(),
514        port,
515        frequency,
516        bandwidth,
517        spreading_factor,
518        coding_rate,
519        modulation: modulation.as_deref(),
520        ifac_netname: ifac_netname.as_deref(),
521        ifac_netkey: ifac_netkey.as_deref(),
522    });
523
524    let now = time::now();
525
526    Some(DiscoveredInterface {
527        interface_type,
528        transport,
529        name,
530        discovered: now,
531        last_heard: now,
532        heard_count: 0,
533        status: DiscoveredStatus::Available,
534        stamp,
535        stamp_value,
536        transport_id,
537        network_id: *announced_identity_hash,
538        hops,
539        latitude,
540        longitude,
541        height,
542        operator_lxmf_address,
543        reachable_on,
544        port,
545        frequency,
546        bandwidth,
547        spreading_factor,
548        coding_rate,
549        modulation,
550        channel,
551        ifac_netname,
552        ifac_netkey,
553        config_entry,
554        discovery_hash,
555    })
556}
557
558/// Compute the discovery hash for storage
559pub fn compute_discovery_hash(transport_id: &[u8; 16], name: &str) -> [u8; 32] {
560    let mut material = Vec::with_capacity(16 + name.len());
561    material.extend_from_slice(transport_id);
562    material.extend_from_slice(name.as_bytes());
563    sha256(&material)
564}
565
566/// Mark a discovered transport interface config entry for gateway mode.
567pub fn apply_transport_autoconnect_mode(
568    iface: &mut DiscoveredInterface,
569    local_transport_enabled: bool,
570    configured_mode: Option<u8>,
571) {
572    let mode = configured_mode.or_else(|| {
573        (local_transport_enabled && iface.transport).then_some(rns_core::constants::MODE_GATEWAY)
574    });
575    let Some(mode) = mode else {
576        return;
577    };
578    let Some(config_entry) = iface.config_entry.as_mut() else {
579        return;
580    };
581    if config_entry
582        .lines()
583        .any(|line| line.trim_start().starts_with("interface_mode"))
584    {
585        return;
586    }
587    if let Some(pos) = config_entry.find("  enabled = yes\n") {
588        let insert_at = pos + "  enabled = yes\n".len();
589        config_entry.insert_str(
590            insert_at,
591            &format!("  interface_mode = {}\n", interface_mode_name(mode)),
592        );
593    } else {
594        config_entry.push_str(&format!(
595            "\n  interface_mode = {}",
596            interface_mode_name(mode)
597        ));
598    }
599}
600
601fn interface_mode_name(mode: u8) -> &'static str {
602    match mode {
603        rns_core::constants::MODE_ACCESS_POINT => "access_point",
604        rns_core::constants::MODE_POINT_TO_POINT => "pointtopoint",
605        rns_core::constants::MODE_ROAMING => "roaming",
606        rns_core::constants::MODE_BOUNDARY => "boundary",
607        rns_core::constants::MODE_GATEWAY => "gateway",
608        rns_core::constants::MODE_INTERNAL => "internal",
609        _ => "full",
610    }
611}
612
613/// Generate a config entry for auto-connecting to a discovered interface
614struct ConfigEntryParams<'a> {
615    interface_type: &'a str,
616    name: &'a str,
617    transport_id: &'a [u8; 16],
618    reachable_on: Option<&'a str>,
619    port: Option<u16>,
620    frequency: Option<u32>,
621    bandwidth: Option<u32>,
622    spreading_factor: Option<u8>,
623    coding_rate: Option<u8>,
624    modulation: Option<&'a str>,
625    ifac_netname: Option<&'a str>,
626    ifac_netkey: Option<&'a str>,
627}
628
629fn generate_config_entry(params: ConfigEntryParams<'_>) -> Option<String> {
630    let ConfigEntryParams {
631        interface_type,
632        name,
633        transport_id,
634        reachable_on,
635        port,
636        frequency,
637        bandwidth,
638        spreading_factor,
639        coding_rate,
640        modulation,
641        ifac_netname,
642        ifac_netkey,
643    } = params;
644    if reachable_on.is_some_and(|address| {
645        is_ygg_ipv6(address) || is_onion_address(address) || is_invalid_ip_address(address)
646    }) {
647        return None;
648    }
649
650    let transport_id_hex = hex_encode(transport_id);
651    let netname_str = ifac_netname
652        .map(|n| format!("\n  network_name = {}", n))
653        .unwrap_or_default();
654    let netkey_str = ifac_netkey
655        .map(|k| format!("\n  passphrase = {}", k))
656        .unwrap_or_default();
657    let identity_str = format!("\n  transport_identity = {}", transport_id_hex);
658
659    match interface_type {
660        "BackboneInterface" | "TCPServerInterface" => {
661            let reachable = reachable_on.unwrap_or("unknown");
662            let port_val = port.unwrap_or(4242);
663            Some(format!(
664                "[[{}]]\n  type = BackboneInterface\n  enabled = yes\n  remote = {}\n  target_port = {}{}{}{}",
665                name, reachable, port_val, identity_str, netname_str, netkey_str
666            ))
667        }
668        "I2PInterface" => {
669            let reachable = reachable_on.unwrap_or("unknown");
670            Some(format!(
671                "[[{}]]\n  type = I2PInterface\n  enabled = yes\n  peers = {}{}{}{}",
672                name, reachable, identity_str, netname_str, netkey_str
673            ))
674        }
675        "RNodeInterface" => {
676            let freq_str = frequency
677                .map(|f| format!("\n  frequency = {}", f))
678                .unwrap_or_default();
679            let bw_str = bandwidth
680                .map(|b| format!("\n  bandwidth = {}", b))
681                .unwrap_or_default();
682            let sf_str = spreading_factor
683                .map(|s| format!("\n  spreadingfactor = {}", s))
684                .unwrap_or_default();
685            let cr_str = coding_rate
686                .map(|c| format!("\n  codingrate = {}", c))
687                .unwrap_or_default();
688            Some(format!(
689                "[[{}]]\n  type = RNodeInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}{}",
690                name, "", freq_str, bw_str, sf_str, cr_str, identity_str, netname_str, netkey_str
691            ))
692        }
693        "KISSInterface" => {
694            let freq_str = frequency
695                .map(|f| format!("\n  # Frequency: {}", f))
696                .unwrap_or_default();
697            let bw_str = bandwidth
698                .map(|b| format!("\n  # Bandwidth: {}", b))
699                .unwrap_or_default();
700            let mod_str = modulation
701                .map(|m| format!("\n  # Modulation: {}", m))
702                .unwrap_or_default();
703            Some(format!(
704                "[[{}]]\n  type = KISSInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}",
705                name, "", freq_str, bw_str, mod_str, identity_str, netname_str, netkey_str
706            ))
707        }
708        "WeaveInterface" => Some(format!(
709            "[[{}]]\n  type = WeaveInterface\n  enabled = yes\n  port = {}{}{}{}",
710            name, "", identity_str, netname_str, netkey_str
711        )),
712        _ => None,
713    }
714}
715
716// ============================================================================
717// Helper Functions
718// ============================================================================
719
720fn optional_f64_field(value: Option<Value>) -> Option<Option<f64>> {
721    match value {
722        None | Some(Value::Nil) => Some(None),
723        Some(Value::Float(value)) if value.is_finite() => Some(Some(value)),
724        Some(_) => None,
725    }
726}
727
728fn discovery_value_to_string(value: &Value) -> String {
729    match value {
730        Value::Nil => "None".to_string(),
731        Value::Bool(value) => value.to_string(),
732        Value::UInt(value) => value.to_string(),
733        Value::Int(value) => value.to_string(),
734        Value::Float(value) => value.to_string(),
735        Value::Bin(value) => hex_encode(value),
736        Value::Str(value) => value.clone(),
737        Value::Array(_) => "[]".to_string(),
738        Value::Map(_) => "{}".to_string(),
739    }
740}
741
742/// Sanitize a discovered interface name like upstream Reticulum.
743pub fn sanitize_discovered_name(name: &str) -> Option<String> {
744    let ascii: String = name.chars().filter(|ch| ch.is_ascii()).collect();
745    let mut sanitized = ascii.trim().to_string();
746    while sanitized.contains("  ") {
747        sanitized = sanitized.replace("  ", " ");
748    }
749
750    let start = sanitized
751        .char_indices()
752        .find(|(_, ch)| ch.is_ascii_alphanumeric())
753        .map(|(idx, _)| idx)?;
754    let end = sanitized
755        .char_indices()
756        .rev()
757        .find(|(_, ch)| ch.is_ascii_alphanumeric() || *ch == ')')
758        .map(|(idx, ch)| idx + ch.len_utf8())?;
759
760    if start >= end {
761        return None;
762    }
763
764    let sanitized = sanitized[start..end].to_string();
765    (!sanitized.is_empty()).then_some(sanitized)
766}
767
768/// Encode bytes as hex string (no delimiters)
769pub fn hex_encode(bytes: &[u8]) -> String {
770    bytes.iter().map(|b| format!("{:02x}", b)).collect()
771}
772
773/// Check if a string is a valid IP address
774pub fn is_ip_address(s: &str) -> bool {
775    s.parse::<std::net::IpAddr>().is_ok()
776}
777
778/// Check if an address belongs to Yggdrasil's `200::/7` IPv6 range.
779pub fn is_ygg_ipv6(s: &str) -> bool {
780    match s.parse::<std::net::IpAddr>() {
781        Ok(std::net::IpAddr::V6(addr)) => {
782            let segments = addr.segments();
783            (segments[0] & 0xfe00) == 0x0200
784        }
785        _ => false,
786    }
787}
788
789/// Check whether an address is a Tor onion service hostname.
790pub fn is_onion_address(s: &str) -> bool {
791    s.to_ascii_lowercase().ends_with(".onion")
792}
793
794/// Check addresses that must never be used for discovery autoconnection.
795pub fn is_invalid_ip_address(s: &str) -> bool {
796    matches!(s, "127.0.0.1" | "0.0.0.0")
797}
798
799/// Check if a string is a valid hostname
800pub fn is_hostname(s: &str) -> bool {
801    let s = s.strip_suffix('.').unwrap_or(s);
802    if s.len() > 253 {
803        return false;
804    }
805    let components: Vec<&str> = s.split('.').collect();
806    if components.is_empty() {
807        return false;
808    }
809    // Last component should not be all numeric
810    if components
811        .last()
812        .map(|c| c.chars().all(|ch| ch.is_ascii_digit()))
813        .unwrap_or(false)
814    {
815        return false;
816    }
817    components.iter().all(|c| {
818        !c.is_empty()
819            && c.len() <= 63
820            && !c.starts_with('-')
821            && !c.ends_with('-')
822            && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
823    })
824}
825
826/// Check whether an interface type can be accepted from discovery.
827pub fn is_discoverable_type(interface_type: &str) -> bool {
828    DISCOVERABLE_TYPES.contains(&interface_type)
829}
830
831/// Filter and sort discovered interfaces
832pub fn filter_and_sort_interfaces(
833    interfaces: &mut Vec<DiscoveredInterface>,
834    only_available: bool,
835    only_transport: bool,
836) {
837    let now = time::now();
838
839    // Update status and filter
840    interfaces.retain(|iface| {
841        if !is_discoverable_type(&iface.interface_type) {
842            return false;
843        }
844        if let Some(ref reachable_on) = iface.reachable_on {
845            if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
846                return false;
847            }
848        }
849
850        let delta = now - iface.last_heard;
851
852        // Check for removal threshold
853        if delta > THRESHOLD_REMOVE {
854            return false;
855        }
856
857        // Update status
858        let status = iface.compute_status();
859
860        // Apply filters
861        if only_available && status != DiscoveredStatus::Available {
862            return false;
863        }
864        if only_transport && !iface.transport {
865            return false;
866        }
867
868        true
869    });
870
871    // Sort by (status_code desc, value desc, last_heard desc)
872    interfaces.sort_by(|a, b| {
873        let status_cmp = b.compute_status().code().cmp(&a.compute_status().code());
874        if status_cmp != std::cmp::Ordering::Equal {
875            return status_cmp;
876        }
877        let value_cmp = b.stamp_value.cmp(&a.stamp_value);
878        if value_cmp != std::cmp::Ordering::Equal {
879            return value_cmp;
880        }
881        b.last_heard
882            .partial_cmp(&a.last_heard)
883            .unwrap_or(std::cmp::Ordering::Equal)
884    });
885}
886
887/// Compute the name hash for the discovery destination: `rnstransport.discovery.interface`.
888///
889/// Discovery is a SINGLE destination — its dest hash varies with the sender's identity.
890/// We match incoming announces by comparing their name_hash to this constant.
891pub fn discovery_name_hash() -> [u8; 10] {
892    rns_core::destination::name_hash(APP_NAME, &["discovery", "interface"])
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    fn pack_discovery_entries(entries: Vec<(Value, Value)>) -> Vec<u8> {
900        let packed = msgpack::pack(&Value::Map(entries));
901        let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
902        app_data.push(0x00);
903        app_data.extend_from_slice(&packed);
904        app_data.extend_from_slice(&[0u8; STAMP_SIZE]);
905        app_data
906    }
907
908    fn discovery_entries(interface_type: &str, reachable_on: Option<&str>) -> Vec<(Value, Value)> {
909        let mut entries = vec![
910            (
911                Value::UInt(INTERFACE_TYPE as u64),
912                Value::Str(interface_type.to_string()),
913            ),
914            (Value::UInt(TRANSPORT as u64), Value::Bool(true)),
915            (
916                Value::UInt(NAME as u64),
917                Value::Str(format!("test-{interface_type}")),
918            ),
919            (Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 16])),
920        ];
921
922        if let Some(reachable_on) = reachable_on {
923            entries.push((
924                Value::UInt(REACHABLE_ON as u64),
925                Value::Str(reachable_on.to_string()),
926            ));
927        }
928
929        entries
930    }
931
932    fn build_discovery_app_data(interface_type: &str, reachable_on: Option<&str>) -> Vec<u8> {
933        pack_discovery_entries(discovery_entries(interface_type, reachable_on))
934    }
935
936    #[test]
937    fn insufficient_stamp_diagnostic_includes_calculated_value() {
938        assert_eq!(
939            insufficient_stamp_diagnostic(7),
940            "Ignored discovered interface with insufficient stamp value 7"
941        );
942    }
943
944    #[test]
945    fn default_discovery_stamp_value_matches_upstream_1_4_0() {
946        assert_eq!(DEFAULT_STAMP_VALUE, 16);
947    }
948
949    #[test]
950    fn invalid_stamp_cache_short_circuits_repeat_validation() {
951        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
952        let mut cache = DiscoveryStampCache::with_capacity(8);
953
954        assert!(parse_interface_announce_with_cache(
955            &app_data,
956            &[0x11; 16],
957            1,
958            u8::MAX,
959            &mut cache,
960        )
961        .is_none());
962        assert_eq!(cache.invalid_len(), 1);
963
964        // A zero requirement would accept this stamp if validation ran again.
965        // The cached invalid payload must be rejected before that work occurs.
966        assert!(
967            parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache,)
968                .is_none()
969        );
970        assert!(parse_interface_announce(&app_data, &[0x11; 16], 1, 0).is_some());
971        assert_eq!(
972            cache.invalid_len(),
973            1,
974            "cache hits must not duplicate entries"
975        );
976    }
977
978    #[test]
979    fn invalid_stamp_cache_is_bounded_fifo() {
980        let first = build_discovery_app_data("BackboneInterface", Some("first.example"));
981        let second = build_discovery_app_data("BackboneInterface", Some("second.example"));
982        let third = build_discovery_app_data("BackboneInterface", Some("third.example"));
983        let mut cache = DiscoveryStampCache::with_capacity(2);
984
985        for app_data in [&first, &second, &third] {
986            assert!(parse_interface_announce_with_cache(
987                app_data,
988                &[0x11; 16],
989                1,
990                u8::MAX,
991                &mut cache,
992            )
993            .is_none());
994        }
995
996        assert_eq!(cache.invalid_len(), 2);
997        assert!(!cache.contains_invalid_app_data(&first));
998        assert!(cache.contains_invalid_app_data(&second));
999        assert!(cache.contains_invalid_app_data(&third));
1000    }
1001
1002    #[test]
1003    fn valid_stamp_cache_short_circuits_repeat_work_and_preserves_dynamic_metadata() {
1004        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1005        let mut cache = DiscoveryStampCache::with_capacity(8);
1006        let first =
1007            parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache).unwrap();
1008        assert_eq!(cache.valid_len(), 1);
1009
1010        let second =
1011            parse_interface_announce_with_cache(&app_data, &[0x22; 16], 7, 0, &mut cache).unwrap();
1012        assert_eq!(cache.valid_len(), 1);
1013        assert_eq!(second.stamp, first.stamp);
1014        assert_eq!(second.stamp_value, first.stamp_value);
1015        assert_eq!(second.network_id, [0x22; 16]);
1016        assert_eq!(second.hops, 7);
1017    }
1018
1019    #[test]
1020    fn valid_stamp_cache_is_bounded_fifo_and_keys_payload_like_invalid_cache() {
1021        let first = build_discovery_app_data("BackboneInterface", Some("first.example"));
1022        let second = build_discovery_app_data("BackboneInterface", Some("second.example"));
1023        let third = build_discovery_app_data("BackboneInterface", Some("third.example"));
1024        let mut cache = DiscoveryStampCache::with_capacity(2);
1025
1026        for app_data in [&first, &second, &third] {
1027            assert!(
1028                parse_interface_announce_with_cache(app_data, &[0x11; 16], 1, 0, &mut cache,)
1029                    .is_some()
1030            );
1031        }
1032        assert_eq!(cache.valid_len(), 2);
1033        assert!(!cache.contains_valid_app_data(&first));
1034        assert!(cache.contains_valid_app_data(&second));
1035        assert!(cache.contains_valid_app_data(&third));
1036
1037        let mut different_flags = second.clone();
1038        different_flags[0] ^= 0x01;
1039        assert!(cache.contains_valid_app_data(&different_flags));
1040        let mut different_stamp = second.clone();
1041        *different_stamp.last_mut().unwrap() ^= 0x01;
1042        assert!(!cache.contains_valid_app_data(&different_stamp));
1043    }
1044
1045    #[test]
1046    fn cached_stamp_value_is_rechecked_if_requirement_increases() {
1047        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1048        let mut cache = DiscoveryStampCache::with_capacity(8);
1049        assert!(
1050            parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache,)
1051                .is_some()
1052        );
1053        assert!(parse_interface_announce_with_cache(
1054            &app_data,
1055            &[0x11; 16],
1056            1,
1057            u8::MAX,
1058            &mut cache,
1059        )
1060        .is_none());
1061        assert_eq!(cache.valid_len(), 1);
1062        assert_eq!(cache.invalid_len(), 0);
1063    }
1064
1065    #[test]
1066    fn invalid_stamp_cache_keys_payload_including_stamp_but_excluding_flags() {
1067        let original = build_discovery_app_data("BackboneInterface", Some("example.com"));
1068        let mut cache = DiscoveryStampCache::with_capacity(8);
1069        assert!(parse_interface_announce_with_cache(
1070            &original,
1071            &[0x11; 16],
1072            1,
1073            u8::MAX,
1074            &mut cache,
1075        )
1076        .is_none());
1077
1078        let mut different_flags = original.clone();
1079        different_flags[0] ^= 0x01;
1080        assert!(cache.contains_invalid_app_data(&different_flags));
1081
1082        let mut different_stamp = original.clone();
1083        *different_stamp.last_mut().unwrap() ^= 0x01;
1084        assert!(!cache.contains_invalid_app_data(&different_stamp));
1085    }
1086
1087    #[test]
1088    fn malformed_discovery_payload_is_not_added_to_invalid_stamp_cache() {
1089        let mut cache = DiscoveryStampCache::with_capacity(8);
1090        assert!(parse_interface_announce_with_cache(
1091            &[0x00, 0x01, 0x02],
1092            &[0x11; 16],
1093            1,
1094            u8::MAX,
1095            &mut cache,
1096        )
1097        .is_none());
1098        assert_eq!(cache.invalid_len(), 0);
1099    }
1100
1101    #[test]
1102    fn parse_rejects_unsupported_discovered_interface_type() {
1103        let app_data = build_discovery_app_data("BogusInterface", None);
1104
1105        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1106
1107        assert!(
1108            parsed.is_none(),
1109            "unsupported discovered interface types must be ignored"
1110        );
1111    }
1112
1113    #[test]
1114    fn parse_rejects_invalid_reachable_on_address() {
1115        let app_data = build_discovery_app_data("BackboneInterface", Some("-not a host-"));
1116
1117        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1118
1119        assert!(
1120            parsed.is_none(),
1121            "discovered interfaces with invalid reachable_on values must be ignored"
1122        );
1123    }
1124
1125    #[test]
1126    fn parse_sanitizes_discovered_interface_name() {
1127        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1128        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1129        entries.push((
1130            Value::UInt(NAME as u64),
1131            Value::Str("\t**Alpha     Beta!!!\n".to_string()),
1132        ));
1133        let app_data = pack_discovery_entries(entries);
1134
1135        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1136
1137        assert_eq!(parsed.name, "Alpha Beta");
1138        assert!(parsed.config_entry.unwrap().starts_with("[[Alpha Beta]]"));
1139    }
1140
1141    #[test]
1142    fn parse_falls_back_when_discovered_interface_name_sanitizes_empty() {
1143        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1144        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1145        entries.push((Value::UInt(NAME as u64), Value::Str("!!!".to_string())));
1146        let app_data = pack_discovery_entries(entries);
1147
1148        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1149
1150        assert_eq!(parsed.name, "Discovered BackboneInterface");
1151    }
1152
1153    #[test]
1154    fn parse_falls_back_when_discovered_interface_name_is_nil() {
1155        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1156        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1157        entries.push((Value::UInt(NAME as u64), Value::Nil));
1158        let app_data = pack_discovery_entries(entries);
1159
1160        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1161
1162        assert_eq!(parsed.name, "Discovered BackboneInterface");
1163    }
1164
1165    #[test]
1166    fn parse_rejects_invalid_discovered_interface_field_types() {
1167        for (field, replacement) in [
1168            (TRANSPORT, Value::Str("yes".to_string())),
1169            (LATITUDE, Value::Str("45.0".to_string())),
1170            (LONGITUDE, Value::Str("9.0".to_string())),
1171            (HEIGHT, Value::Str("100".to_string())),
1172            (INTERFACE_TYPE, Value::UInt(123)),
1173            (REACHABLE_ON, Value::UInt(123)),
1174        ] {
1175            let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1176            entries.retain(|(key, _)| key.as_uint() != Some(field as u64));
1177            entries.push((Value::UInt(field as u64), replacement));
1178            let app_data = pack_discovery_entries(entries);
1179
1180            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1181
1182            assert!(parsed.is_none(), "field {field} should reject invalid type");
1183        }
1184    }
1185
1186    #[test]
1187    fn parse_rejects_invalid_transport_id_length() {
1188        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1189        entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT_ID as u64));
1190        entries.push((Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 15])));
1191        let app_data = pack_discovery_entries(entries);
1192
1193        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1194
1195        assert!(parsed.is_none());
1196    }
1197
1198    #[test]
1199    fn parse_converts_ifac_fields_to_strings() {
1200        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1201        entries.push((Value::UInt(IFAC_NETNAME as u64), Value::UInt(123)));
1202        entries.push((Value::UInt(IFAC_NETKEY as u64), Value::Bool(true)));
1203        let app_data = pack_discovery_entries(entries);
1204
1205        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1206
1207        assert_eq!(parsed.ifac_netname.as_deref(), Some("123"));
1208        assert_eq!(parsed.ifac_netkey.as_deref(), Some("true"));
1209    }
1210
1211    #[test]
1212    fn parse_accepts_exact_operator_lxmf_address() {
1213        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1214        entries.push((Value::UInt(OP_ADDR as u64), Value::Bin(vec![0x5a; 16])));
1215        let app_data = pack_discovery_entries(entries);
1216
1217        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1218
1219        assert_eq!(parsed.operator_lxmf_address, Some([0x5a; 16]));
1220    }
1221
1222    #[test]
1223    fn parse_rejects_non_binary_operator_lxmf_address() {
1224        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1225        entries.push((Value::UInt(OP_ADDR as u64), Value::Str("5a".repeat(16))));
1226        let app_data = pack_discovery_entries(entries);
1227
1228        assert!(parse_interface_announce(&app_data, &[0x11; 16], 1, 0).is_none());
1229    }
1230
1231    #[test]
1232    fn parse_ignores_operator_lxmf_address_with_wrong_length() {
1233        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1234        entries.push((Value::UInt(OP_ADDR as u64), Value::Bin(vec![0x5a; 15])));
1235        let app_data = pack_discovery_entries(entries);
1236
1237        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1238
1239        assert_eq!(parsed.operator_lxmf_address, None);
1240    }
1241
1242    #[test]
1243    fn unsafe_discovery_endpoints_are_retained_without_autoconnect_config() {
1244        for address in ["example.onion", "127.0.0.1", "0.0.0.0"] {
1245            let app_data = build_discovery_app_data("BackboneInterface", Some(address));
1246            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1247
1248            assert_eq!(parsed.reachable_on.as_deref(), Some(address));
1249            assert_eq!(parsed.config_entry, None, "{address} must not autoconnect");
1250        }
1251    }
1252
1253    #[test]
1254    fn transport_autoconnect_mode_marks_transport_discovery_config_as_gateway() {
1255        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1256        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1257
1258        apply_transport_autoconnect_mode(&mut parsed, true, None);
1259
1260        let config_entry = parsed.config_entry.unwrap();
1261        assert!(config_entry.contains("  enabled = yes\n  interface_mode = gateway\n"));
1262    }
1263
1264    #[test]
1265    fn configured_autoconnect_mode_overrides_transport_fallback() {
1266        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1267        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1268
1269        apply_transport_autoconnect_mode(
1270            &mut parsed,
1271            true,
1272            Some(rns_core::constants::MODE_BOUNDARY),
1273        );
1274
1275        let config_entry = parsed.config_entry.unwrap();
1276        assert!(config_entry.contains("  enabled = yes\n  interface_mode = boundary\n"));
1277    }
1278
1279    #[test]
1280    fn transport_autoconnect_mode_does_not_modify_non_transport_contexts() {
1281        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1282        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1283        let original = parsed.config_entry.clone();
1284
1285        apply_transport_autoconnect_mode(&mut parsed, false, None);
1286
1287        assert_eq!(parsed.config_entry, original);
1288    }
1289
1290    #[test]
1291    fn transport_autoconnect_mode_does_not_modify_non_transport_announces() {
1292        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1293        entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT as u64));
1294        entries.push((Value::UInt(TRANSPORT as u64), Value::Bool(false)));
1295        let app_data = pack_discovery_entries(entries);
1296        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1297        let original = parsed.config_entry.clone();
1298
1299        apply_transport_autoconnect_mode(&mut parsed, true, None);
1300
1301        assert_eq!(parsed.config_entry, original);
1302    }
1303
1304    #[test]
1305    fn transport_autoconnect_mode_preserves_existing_interface_mode() {
1306        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1307        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1308        let config_entry = parsed.config_entry.as_mut().unwrap();
1309        config_entry.push_str("\n  interface_mode = access_point");
1310        let original = parsed.config_entry.clone();
1311
1312        apply_transport_autoconnect_mode(&mut parsed, true, None);
1313
1314        assert_eq!(parsed.config_entry, original);
1315    }
1316
1317    #[test]
1318    fn parse_yggdrasil_reachable_on_keeps_record_without_config_entry() {
1319        let app_data = build_discovery_app_data("BackboneInterface", Some("200:1234::1"));
1320
1321        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1322
1323        assert_eq!(parsed.reachable_on.as_deref(), Some("200:1234::1"));
1324        assert!(parsed.config_entry.is_none());
1325    }
1326
1327    #[test]
1328    fn parse_accepts_supported_discovered_interface_types() {
1329        for interface_type in [
1330            "BackboneInterface",
1331            "TCPServerInterface",
1332            "I2PInterface",
1333            "RNodeInterface",
1334            "WeaveInterface",
1335            "KISSInterface",
1336        ] {
1337            let app_data = build_discovery_app_data(interface_type, Some("example.com"));
1338
1339            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1340
1341            assert!(
1342                parsed.is_some(),
1343                "{interface_type} should be accepted as a discoverable interface type"
1344            );
1345        }
1346    }
1347}