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