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