Skip to main content

rns_net/common/
config.rs

1//! ConfigObj parser for RNS config files.
2//!
3//! Python RNS uses ConfigObj format — NOT TOML, NOT standard INI.
4//! Key differences: nested `[[sections]]`, booleans `Yes`/`No`/`True`/`False`,
5//! comments with `#`, unquoted string values.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::io;
10use std::path::Path;
11
12/// Parsed RNS configuration.
13#[derive(Debug, Clone)]
14pub struct RnsConfig {
15    pub reticulum: ReticulumSection,
16    pub logging: LoggingSection,
17    pub interfaces: Vec<ParsedInterface>,
18    pub hooks: Vec<ParsedHook>,
19}
20
21/// A parsed hook from `[[subsection]]` within `[hooks]`.
22#[derive(Debug, Clone)]
23pub struct ParsedHook {
24    pub name: String,
25    pub path: String,
26    pub hook_type: String,
27    pub builtin_id: Option<String>,
28    pub attach_point: String,
29    pub priority: i32,
30    pub enabled: bool,
31}
32
33/// The `[reticulum]` section.
34#[derive(Debug, Clone)]
35pub struct ReticulumSection {
36    pub enable_transport: bool,
37    pub share_instance: bool,
38    pub instance_name: String,
39    pub shared_instance_port: u16,
40    pub instance_control_port: u16,
41    pub panic_on_interface_error: bool,
42    pub use_implicit_proof: bool,
43    pub network_identity: Option<String>,
44    pub respond_to_probes: bool,
45    pub enable_remote_management: bool,
46    pub remote_management_allowed: Vec<String>,
47    pub publish_blackhole: bool,
48    pub probe_port: Option<u16>,
49    pub probe_addr: Option<String>,
50    /// Protocol for endpoint discovery: "rnsp" (default) or "stun".
51    pub probe_protocol: Option<String>,
52    /// Network interface to bind outbound sockets to (e.g. "usb0").
53    pub device: Option<String>,
54    /// Enable interface discovery (advertise discoverable interfaces and
55    /// listen for discovery announces from the network).
56    pub discover_interfaces: bool,
57    /// Minimum stamp value for accepting discovered interfaces.
58    pub required_discovery_value: Option<u8>,
59    /// Accept an announce with strictly fewer hops even when the random_blob
60    /// is a duplicate of the existing path entry.
61    pub prefer_shorter_path: bool,
62    /// Maximum number of alternative paths stored per destination.
63    /// Default 1 (single path, backward-compatible).
64    pub max_paths_per_destination: usize,
65    /// Maximum number of packet hashes retained for duplicate suppression.
66    pub packet_hashlist_max_entries: usize,
67    /// Maximum number of discovery path-request tags remembered.
68    pub max_discovery_pr_tags: usize,
69    /// Maximum number of destinations retained in the live path table.
70    pub max_path_destinations: usize,
71    /// Maximum number of destinations retained across tunnel-known paths.
72    pub max_tunnel_destinations_total: usize,
73    /// TTL for recalled known destinations without an active path, in seconds.
74    pub known_destinations_ttl: u64,
75    /// Maximum number of recalled known destinations retained.
76    pub known_destinations_max_entries: usize,
77    /// TTL for received ratchets, in seconds.
78    pub ratchet_expiry: u64,
79    /// TTL for announce retransmission state, in seconds.
80    pub announce_table_ttl: u64,
81    /// Maximum retained bytes for announce retransmission state.
82    pub announce_table_max_bytes: usize,
83    /// Whether the announce signature verification cache is enabled.
84    pub announce_sig_cache_enabled: bool,
85    /// Maximum entries in the announce signature verification cache.
86    pub announce_sig_cache_max_entries: usize,
87    /// TTL for announce signature cache entries, in seconds.
88    pub announce_sig_cache_ttl: u64,
89    /// Maximum entries in the async announce verification queue.
90    pub announce_queue_max_entries: usize,
91    /// Maximum interface-scoped announce queues retained.
92    pub announce_queue_max_interfaces: usize,
93    /// Default announce-rate target for transport-node interfaces, in seconds.
94    pub default_ar_target: Option<f64>,
95    /// Default announce-rate penalty for transport-node interfaces, in seconds.
96    pub default_ar_penalty: f64,
97    /// Default announce-rate grace count for transport-node interfaces.
98    pub default_ar_grace: u32,
99    /// Default maximum held announces for ingress control.
100    pub default_ic_max_held_announces: usize,
101    /// Default announce burst hold time for ingress control.
102    pub default_ic_burst_hold: f64,
103    /// Default new-interface announce burst threshold.
104    pub default_ic_burst_freq_new: f64,
105    /// Default mature-interface announce burst threshold.
106    pub default_ic_burst_freq: f64,
107    /// Default new-interface path request burst threshold.
108    pub default_ic_pr_burst_freq_new: f64,
109    /// Default mature-interface path request burst threshold.
110    pub default_ic_pr_burst_freq: f64,
111    /// Default new-interface age window for ingress control.
112    pub default_ic_new_time: f64,
113    /// Default burst penalty before releasing held announces.
114    pub default_ic_burst_penalty: f64,
115    /// Default interval between released held announces.
116    pub default_ic_held_release_interval: f64,
117    /// Default egress path request limiting state.
118    pub default_egress_control: bool,
119    /// Default egress path request frequency threshold.
120    pub default_ec_pr_freq: f64,
121    /// Maximum retained bytes in the async announce verification queue.
122    pub announce_queue_max_bytes: usize,
123    /// TTL for queued async announce verification entries, in seconds.
124    pub announce_queue_ttl: u64,
125    /// Overflow policy for the async announce verification queue.
126    pub announce_queue_overflow_policy: String,
127    /// Maximum queued events awaiting driver processing.
128    pub driver_event_queue_capacity: usize,
129    /// Maximum queued outbound frames per interface writer worker.
130    pub interface_writer_queue_capacity: usize,
131    /// Maximum active outbound Backbone peer-pool connections. Zero disables pooling.
132    pub backbone_peer_pool_max_connected: usize,
133    /// Failures within the failure window before a pooled Backbone peer enters cooldown.
134    pub backbone_peer_pool_failure_threshold: usize,
135    /// Failure accounting window for pooled Backbone peers, in seconds.
136    pub backbone_peer_pool_failure_window: u64,
137    /// Cooldown duration for failed pooled Backbone peers, in seconds.
138    pub backbone_peer_pool_cooldown: u64,
139    #[cfg(feature = "hooks")]
140    pub provider_bridge: bool,
141    #[cfg(feature = "hooks")]
142    pub provider_socket_path: Option<String>,
143    #[cfg(feature = "hooks")]
144    pub provider_queue_max_events: usize,
145    #[cfg(feature = "hooks")]
146    pub provider_queue_max_bytes: usize,
147    #[cfg(feature = "hooks")]
148    pub provider_overflow_policy: String,
149}
150
151impl Default for ReticulumSection {
152    fn default() -> Self {
153        ReticulumSection {
154            enable_transport: false,
155            share_instance: true,
156            instance_name: "default".into(),
157            shared_instance_port: 37428,
158            instance_control_port: 37429,
159            panic_on_interface_error: false,
160            use_implicit_proof: true,
161            network_identity: None,
162            respond_to_probes: false,
163            enable_remote_management: false,
164            remote_management_allowed: Vec::new(),
165            publish_blackhole: false,
166            probe_port: None,
167            probe_addr: None,
168            probe_protocol: None,
169            device: None,
170            discover_interfaces: false,
171            required_discovery_value: None,
172            prefer_shorter_path: false,
173            max_paths_per_destination: 1,
174            packet_hashlist_max_entries: rns_core::constants::HASHLIST_MAXSIZE,
175            max_discovery_pr_tags: rns_core::constants::MAX_PR_TAGS,
176            max_path_destinations: rns_core::transport::types::DEFAULT_MAX_PATH_DESTINATIONS,
177            max_tunnel_destinations_total: usize::MAX,
178            known_destinations_ttl: 48 * 60 * 60,
179            known_destinations_max_entries: 8192,
180            ratchet_expiry: rns_core::constants::RATCHET_EXPIRY,
181            announce_table_ttl: rns_core::constants::ANNOUNCE_TABLE_TTL as u64,
182            announce_table_max_bytes: rns_core::constants::ANNOUNCE_TABLE_MAX_BYTES,
183            announce_sig_cache_enabled: true,
184            announce_sig_cache_max_entries: rns_core::constants::ANNOUNCE_SIG_CACHE_MAXSIZE,
185            announce_sig_cache_ttl: rns_core::constants::ANNOUNCE_SIG_CACHE_TTL as u64,
186            announce_queue_max_entries: 256,
187            announce_queue_max_interfaces: 1024,
188            default_ar_target: Some(3600.0),
189            default_ar_penalty: 0.0,
190            default_ar_grace: 5,
191            default_ic_max_held_announces: rns_core::constants::IC_MAX_HELD_ANNOUNCES,
192            default_ic_burst_hold: rns_core::constants::IC_BURST_HOLD,
193            default_ic_burst_freq_new: rns_core::constants::IC_BURST_FREQ_NEW,
194            default_ic_burst_freq: rns_core::constants::IC_BURST_FREQ,
195            default_ic_pr_burst_freq_new: rns_core::constants::IC_PR_BURST_FREQ_NEW,
196            default_ic_pr_burst_freq: rns_core::constants::IC_PR_BURST_FREQ,
197            default_ic_new_time: rns_core::constants::IC_NEW_TIME,
198            default_ic_burst_penalty: rns_core::constants::IC_BURST_PENALTY,
199            default_ic_held_release_interval: rns_core::constants::IC_HELD_RELEASE_INTERVAL,
200            default_egress_control: false,
201            default_ec_pr_freq: rns_core::constants::EC_PR_FREQ,
202            announce_queue_max_bytes: 256 * 1024,
203            announce_queue_ttl: 30,
204            announce_queue_overflow_policy: "drop_worst".into(),
205            driver_event_queue_capacity: crate::event::DEFAULT_EVENT_QUEUE_CAPACITY,
206            interface_writer_queue_capacity: crate::interface::DEFAULT_ASYNC_WRITER_QUEUE_CAPACITY,
207            backbone_peer_pool_max_connected: 0,
208            backbone_peer_pool_failure_threshold: 3,
209            backbone_peer_pool_failure_window: 600,
210            backbone_peer_pool_cooldown: 900,
211            #[cfg(feature = "hooks")]
212            provider_bridge: false,
213            #[cfg(feature = "hooks")]
214            provider_socket_path: None,
215            #[cfg(feature = "hooks")]
216            provider_queue_max_events: 16384,
217            #[cfg(feature = "hooks")]
218            provider_queue_max_bytes: 8 * 1024 * 1024,
219            #[cfg(feature = "hooks")]
220            provider_overflow_policy: "drop_newest".into(),
221        }
222    }
223}
224
225/// The `[logging]` section.
226#[derive(Debug, Clone)]
227pub struct LoggingSection {
228    pub loglevel: u8,
229    pub logtimestamps: bool,
230}
231
232impl Default for LoggingSection {
233    fn default() -> Self {
234        LoggingSection {
235            loglevel: 4,
236            logtimestamps: true,
237        }
238    }
239}
240
241/// A parsed interface from `[[subsection]]` within `[interfaces]`.
242#[derive(Debug, Clone)]
243pub struct ParsedInterface {
244    pub name: String,
245    pub interface_type: String,
246    pub enabled: bool,
247    pub mode: String,
248    pub params: HashMap<String, String>,
249}
250
251/// Configuration parse error.
252#[derive(Debug, Clone)]
253pub enum ConfigError {
254    Io(String),
255    Parse(String),
256    InvalidValue { key: String, value: String },
257}
258
259impl fmt::Display for ConfigError {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        match self {
262            ConfigError::Io(msg) => write!(f, "Config I/O error: {}", msg),
263            ConfigError::Parse(msg) => write!(f, "Config parse error: {}", msg),
264            ConfigError::InvalidValue { key, value } => {
265                write!(f, "Invalid value for '{}': '{}'", key, value)
266            }
267        }
268    }
269}
270
271impl From<io::Error> for ConfigError {
272    fn from(e: io::Error) -> Self {
273        ConfigError::Io(e.to_string())
274    }
275}
276
277/// Parse a config string into an `RnsConfig`.
278pub fn parse(input: &str) -> Result<RnsConfig, ConfigError> {
279    let mut current_section: Option<String> = None;
280    let mut current_subsection: Option<String> = None;
281
282    let mut reticulum_kvs: HashMap<String, String> = HashMap::new();
283    let mut logging_kvs: HashMap<String, String> = HashMap::new();
284    let mut interfaces: Vec<ParsedInterface> = Vec::new();
285    let mut current_iface_kvs: Option<HashMap<String, String>> = None;
286    let mut current_iface_name: Option<String> = None;
287    let mut hooks: Vec<ParsedHook> = Vec::new();
288    let mut current_hook_kvs: Option<HashMap<String, String>> = None;
289    let mut current_hook_name: Option<String> = None;
290
291    for line in input.lines() {
292        // Strip comments (# to end of line, unless inside quotes)
293        let line = strip_comment(line);
294        let trimmed = line.trim();
295
296        // Skip empty lines
297        if trimmed.is_empty() {
298            continue;
299        }
300
301        // Check for subsection [[name]]
302        if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
303            let name = trimmed[2..trimmed.len() - 2].trim().to_string();
304            // Finalize previous interface subsection if any
305            if let (Some(iface_name), Some(kvs)) =
306                (current_iface_name.take(), current_iface_kvs.take())
307            {
308                interfaces.push(build_parsed_interface(iface_name, kvs));
309            }
310            // Finalize previous hook subsection if any
311            if let (Some(hook_name), Some(kvs)) =
312                (current_hook_name.take(), current_hook_kvs.take())
313            {
314                hooks.push(build_parsed_hook(hook_name, kvs));
315            }
316            current_subsection = Some(name.clone());
317            // Determine which section we're in to know subsection type
318            if current_section.as_deref() == Some("hooks") {
319                current_hook_name = Some(name);
320                current_hook_kvs = Some(HashMap::new());
321            } else {
322                current_iface_name = Some(name);
323                current_iface_kvs = Some(HashMap::new());
324            }
325            continue;
326        }
327
328        // Check for section [name]
329        if trimmed.starts_with('[') && trimmed.ends_with(']') {
330            // Finalize previous interface subsection if any
331            if let (Some(iface_name), Some(kvs)) =
332                (current_iface_name.take(), current_iface_kvs.take())
333            {
334                interfaces.push(build_parsed_interface(iface_name, kvs));
335            }
336            // Finalize previous hook subsection if any
337            if let (Some(hook_name), Some(kvs)) =
338                (current_hook_name.take(), current_hook_kvs.take())
339            {
340                hooks.push(build_parsed_hook(hook_name, kvs));
341            }
342            current_subsection = None;
343
344            let name = trimmed[1..trimmed.len() - 1].trim().to_lowercase();
345            current_section = Some(name);
346            continue;
347        }
348
349        // Parse key = value
350        if let Some(eq_pos) = trimmed.find('=') {
351            let key = trimmed[..eq_pos].trim().to_string();
352            let value = trimmed[eq_pos + 1..].trim().to_string();
353
354            if current_subsection.is_some() {
355                // Inside a [[subsection]] — exactly one of these should be Some
356                debug_assert!(
357                    !(current_hook_kvs.is_some() && current_iface_kvs.is_some()),
358                    "hook and interface subsections should never be active simultaneously"
359                );
360                if let Some(ref mut kvs) = current_hook_kvs {
361                    kvs.insert(key, value);
362                } else if let Some(ref mut kvs) = current_iface_kvs {
363                    kvs.insert(key, value);
364                }
365            } else if let Some(ref section) = current_section {
366                match section.as_str() {
367                    "reticulum" => {
368                        reticulum_kvs.insert(key, value);
369                    }
370                    "logging" => {
371                        logging_kvs.insert(key, value);
372                    }
373                    _ => {} // ignore unknown sections
374                }
375            }
376        }
377    }
378
379    // Finalize last subsections
380    if let (Some(iface_name), Some(kvs)) = (current_iface_name.take(), current_iface_kvs.take()) {
381        interfaces.push(build_parsed_interface(iface_name, kvs));
382    }
383    if let (Some(hook_name), Some(kvs)) = (current_hook_name.take(), current_hook_kvs.take()) {
384        hooks.push(build_parsed_hook(hook_name, kvs));
385    }
386
387    // Build typed sections
388    let reticulum = build_reticulum_section(&reticulum_kvs)?;
389    let logging = build_logging_section(&logging_kvs)?;
390
391    Ok(RnsConfig {
392        reticulum,
393        logging,
394        interfaces,
395        hooks,
396    })
397}
398
399/// Parse a config file from disk.
400pub fn parse_file(path: &Path) -> Result<RnsConfig, ConfigError> {
401    let content = std::fs::read_to_string(path)?;
402    parse(&content)
403}
404
405/// Strip `#` comments from a line (simple: not inside quotes).
406fn strip_comment(line: &str) -> &str {
407    // Find # that is not inside quotes
408    let mut in_quote = false;
409    let mut quote_char = '"';
410    for (i, ch) in line.char_indices() {
411        if !in_quote && (ch == '"' || ch == '\'') {
412            in_quote = true;
413            quote_char = ch;
414        } else if in_quote && ch == quote_char {
415            in_quote = false;
416        } else if !in_quote && ch == '#' {
417            return &line[..i];
418        }
419    }
420    line
421}
422
423/// Parse a string as a boolean (ConfigObj style). Public API for use by node.rs.
424pub fn parse_bool_pub(value: &str) -> Option<bool> {
425    parse_bool(value)
426}
427
428/// Parse a string as a boolean (ConfigObj style).
429fn parse_bool(value: &str) -> Option<bool> {
430    match value.to_lowercase().as_str() {
431        "yes" | "true" | "1" | "on" => Some(true),
432        "no" | "false" | "0" | "off" => Some(false),
433        _ => None,
434    }
435}
436
437fn parse_nonnegative_f64_option(
438    kvs: &HashMap<String, String>,
439    key: &str,
440) -> Result<Option<f64>, ConfigError> {
441    let Some(v) = kvs.get(key) else {
442        return Ok(None);
443    };
444    let parsed = v.parse::<f64>().map_err(|_| ConfigError::InvalidValue {
445        key: key.into(),
446        value: v.clone(),
447    })?;
448    if !parsed.is_finite() || parsed < 0.0 {
449        return Err(ConfigError::InvalidValue {
450            key: key.into(),
451            value: v.clone(),
452        });
453    }
454    Ok(Some(parsed))
455}
456
457fn parse_usize_option(
458    kvs: &HashMap<String, String>,
459    key: &str,
460) -> Result<Option<usize>, ConfigError> {
461    let Some(v) = kvs.get(key) else {
462        return Ok(None);
463    };
464    let parsed = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
465        key: key.into(),
466        value: v.clone(),
467    })?;
468    Ok(Some(parsed))
469}
470
471fn build_parsed_interface(name: String, mut kvs: HashMap<String, String>) -> ParsedInterface {
472    let interface_type = kvs.remove("type").unwrap_or_default();
473    let enabled = kvs
474        .remove("enabled")
475        .and_then(|v| parse_bool(&v))
476        .unwrap_or(true);
477    // Python checks `interface_mode` first, then falls back to `mode`
478    let mode = kvs
479        .remove("interface_mode")
480        .or_else(|| kvs.remove("mode"))
481        .unwrap_or_else(|| "full".into());
482
483    ParsedInterface {
484        name,
485        interface_type,
486        enabled,
487        mode,
488        params: kvs,
489    }
490}
491
492fn build_parsed_hook(name: String, mut kvs: HashMap<String, String>) -> ParsedHook {
493    let path = kvs.remove("path").unwrap_or_default();
494    let hook_type = kvs
495        .remove("type")
496        .or_else(|| kvs.remove("backend"))
497        .unwrap_or_else(|| default_hook_type().into());
498    let builtin_id = kvs
499        .remove("builtin")
500        .or_else(|| kvs.remove("builtin_id"))
501        .or_else(|| kvs.remove("id"));
502    let attach_point = kvs.remove("attach_point").unwrap_or_default();
503    let priority = kvs
504        .remove("priority")
505        .and_then(|v| v.parse::<i32>().ok())
506        .unwrap_or(0);
507    let enabled = kvs
508        .remove("enabled")
509        .and_then(|v| parse_bool(&v))
510        .unwrap_or(true);
511
512    ParsedHook {
513        name,
514        path,
515        hook_type,
516        builtin_id,
517        attach_point,
518        priority,
519        enabled,
520    }
521}
522
523fn default_hook_type() -> &'static str {
524    #[cfg(feature = "rns-hooks-native")]
525    {
526        return "native";
527    }
528    #[cfg(all(not(feature = "rns-hooks-native"), feature = "rns-hooks-wasm"))]
529    {
530        return "wasm";
531    }
532    #[cfg(all(not(feature = "rns-hooks-native"), not(feature = "rns-hooks-wasm")))]
533    {
534        "wasm"
535    }
536}
537
538/// Map a hook point name string to its index. Returns None for unknown names.
539pub fn parse_hook_point(s: &str) -> Option<usize> {
540    match s {
541        "PreIngress" => Some(0),
542        "PreDispatch" => Some(1),
543        "AnnounceReceived" => Some(2),
544        "PathUpdated" => Some(3),
545        "AnnounceRetransmit" => Some(4),
546        "LinkRequestReceived" => Some(5),
547        "LinkEstablished" => Some(6),
548        "LinkClosed" => Some(7),
549        "InterfaceUp" => Some(8),
550        "InterfaceDown" => Some(9),
551        "InterfaceConfigChanged" => Some(10),
552        "BackbonePeerConnected" => Some(11),
553        "BackbonePeerDisconnected" => Some(12),
554        "BackbonePeerIdleTimeout" => Some(13),
555        "BackbonePeerWriteStall" => Some(14),
556        "BackbonePeerPenalty" => Some(15),
557        "SendOnInterface" => Some(16),
558        "BroadcastOnAllInterfaces" => Some(17),
559        "DeliverLocal" => Some(18),
560        "TunnelSynthesize" => Some(19),
561        "Tick" => Some(20),
562        _ => None,
563    }
564}
565
566#[cfg(feature = "hooks")]
567pub fn parse_hook_backend(s: &str) -> Result<rns_hooks::HookBackend, String> {
568    s.parse()
569}
570
571fn build_reticulum_section(kvs: &HashMap<String, String>) -> Result<ReticulumSection, ConfigError> {
572    let mut section = ReticulumSection::default();
573
574    if let Some(v) = kvs.get("enable_transport") {
575        section.enable_transport = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
576            key: "enable_transport".into(),
577            value: v.clone(),
578        })?;
579    }
580    if let Some(v) = kvs.get("share_instance") {
581        section.share_instance = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
582            key: "share_instance".into(),
583            value: v.clone(),
584        })?;
585    }
586    if let Some(v) = kvs.get("instance_name") {
587        section.instance_name = v.clone();
588    }
589    if let Some(v) = kvs.get("shared_instance_port") {
590        section.shared_instance_port = v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
591            key: "shared_instance_port".into(),
592            value: v.clone(),
593        })?;
594    }
595    if let Some(v) = kvs.get("instance_control_port") {
596        section.instance_control_port =
597            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
598                key: "instance_control_port".into(),
599                value: v.clone(),
600            })?;
601    }
602    if let Some(v) = kvs.get("panic_on_interface_error") {
603        section.panic_on_interface_error =
604            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
605                key: "panic_on_interface_error".into(),
606                value: v.clone(),
607            })?;
608    }
609    if let Some(v) = kvs.get("use_implicit_proof") {
610        section.use_implicit_proof = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
611            key: "use_implicit_proof".into(),
612            value: v.clone(),
613        })?;
614    }
615    if let Some(v) = kvs.get("network_identity") {
616        section.network_identity = Some(v.clone());
617    }
618    if let Some(v) = kvs.get("respond_to_probes") {
619        section.respond_to_probes = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
620            key: "respond_to_probes".into(),
621            value: v.clone(),
622        })?;
623    }
624    if let Some(v) = kvs.get("enable_remote_management") {
625        section.enable_remote_management =
626            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
627                key: "enable_remote_management".into(),
628                value: v.clone(),
629            })?;
630    }
631    if let Some(v) = kvs.get("remote_management_allowed") {
632        // Value is a comma-separated list of hex identity hashes
633        for item in v.split(',') {
634            let trimmed = item.trim();
635            if !trimmed.is_empty() {
636                section.remote_management_allowed.push(trimmed.to_string());
637            }
638        }
639    }
640    if let Some(v) = kvs.get("publish_blackhole") {
641        section.publish_blackhole = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
642            key: "publish_blackhole".into(),
643            value: v.clone(),
644        })?;
645    }
646    if let Some(v) = kvs.get("probe_port") {
647        section.probe_port = Some(v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
648            key: "probe_port".into(),
649            value: v.clone(),
650        })?);
651    }
652    if let Some(v) = kvs.get("probe_addr") {
653        section.probe_addr = Some(v.clone());
654    }
655    if let Some(v) = kvs.get("probe_protocol") {
656        section.probe_protocol = Some(v.clone());
657    }
658    if let Some(v) = kvs.get("device") {
659        section.device = Some(v.clone());
660    }
661    if let Some(v) = kvs.get("discover_interfaces") {
662        section.discover_interfaces = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
663            key: "discover_interfaces".into(),
664            value: v.clone(),
665        })?;
666    }
667    if let Some(v) = kvs.get("required_discovery_value") {
668        section.required_discovery_value =
669            Some(v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
670                key: "required_discovery_value".into(),
671                value: v.clone(),
672            })?);
673    }
674    if let Some(v) = kvs.get("prefer_shorter_path") {
675        section.prefer_shorter_path = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
676            key: "prefer_shorter_path".into(),
677            value: v.clone(),
678        })?;
679    }
680    if let Some(v) = kvs.get("max_paths_per_destination") {
681        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
682            key: "max_paths_per_destination".into(),
683            value: v.clone(),
684        })?;
685        section.max_paths_per_destination = n.max(1);
686    }
687    if let Some(v) = kvs.get("packet_hashlist_max_entries") {
688        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
689            key: "packet_hashlist_max_entries".into(),
690            value: v.clone(),
691        })?;
692        section.packet_hashlist_max_entries = n.max(1);
693    }
694    if let Some(v) = kvs.get("max_discovery_pr_tags") {
695        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
696            key: "max_discovery_pr_tags".into(),
697            value: v.clone(),
698        })?;
699        section.max_discovery_pr_tags = n.max(1);
700    }
701    if let Some(v) = kvs.get("max_path_destinations") {
702        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
703            key: "max_path_destinations".into(),
704            value: v.clone(),
705        })?;
706        section.max_path_destinations = n.max(1);
707    }
708    if let Some(v) = kvs.get("max_tunnel_destinations_total") {
709        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
710            key: "max_tunnel_destinations_total".into(),
711            value: v.clone(),
712        })?;
713        section.max_tunnel_destinations_total = n.max(1);
714    }
715    if let Some(v) = kvs.get("known_destinations_ttl") {
716        section.known_destinations_ttl =
717            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
718                key: "known_destinations_ttl".into(),
719                value: v.clone(),
720            })?;
721    }
722    if let Some(v) = kvs.get("known_destinations_max_entries") {
723        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
724            key: "known_destinations_max_entries".into(),
725            value: v.clone(),
726        })?;
727        if n == 0 {
728            return Err(ConfigError::InvalidValue {
729                key: "known_destinations_max_entries".into(),
730                value: v.clone(),
731            });
732        }
733        section.known_destinations_max_entries = n;
734    }
735    if let Some(v) = kvs.get("ratchet_expiry") {
736        let expiry = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
737            key: "ratchet_expiry".into(),
738            value: v.clone(),
739        })?;
740        if expiry == 0 {
741            return Err(ConfigError::InvalidValue {
742                key: "ratchet_expiry".into(),
743                value: v.clone(),
744            });
745        }
746        section.ratchet_expiry = expiry;
747    }
748    if let Some(v) = kvs.get("destination_timeout_secs") {
749        section.known_destinations_ttl =
750            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
751                key: "destination_timeout_secs".into(),
752                value: v.clone(),
753            })?;
754    }
755    if let Some(v) = kvs.get("announce_table_ttl") {
756        let ttl = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
757            key: "announce_table_ttl".into(),
758            value: v.clone(),
759        })?;
760        if ttl == 0 {
761            return Err(ConfigError::InvalidValue {
762                key: "announce_table_ttl".into(),
763                value: v.clone(),
764            });
765        }
766        section.announce_table_ttl = ttl;
767    }
768    if let Some(v) = kvs.get("announce_table_max_bytes") {
769        let max_bytes = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
770            key: "announce_table_max_bytes".into(),
771            value: v.clone(),
772        })?;
773        if max_bytes == 0 {
774            return Err(ConfigError::InvalidValue {
775                key: "announce_table_max_bytes".into(),
776                value: v.clone(),
777            });
778        }
779        section.announce_table_max_bytes = max_bytes;
780    }
781    if let Some(v) = kvs.get("announce_signature_cache_enabled") {
782        section.announce_sig_cache_enabled = match v.as_str() {
783            "true" | "yes" | "True" | "Yes" => true,
784            "false" | "no" | "False" | "No" => false,
785            _ => {
786                return Err(ConfigError::InvalidValue {
787                    key: "announce_signature_cache_enabled".into(),
788                    value: v.clone(),
789                })
790            }
791        };
792    }
793    if let Some(v) = kvs.get("announce_signature_cache_max_entries") {
794        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
795            key: "announce_signature_cache_max_entries".into(),
796            value: v.clone(),
797        })?;
798        section.announce_sig_cache_max_entries = n;
799    }
800    if let Some(v) = kvs.get("announce_signature_cache_ttl") {
801        let ttl = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
802            key: "announce_signature_cache_ttl".into(),
803            value: v.clone(),
804        })?;
805        section.announce_sig_cache_ttl = ttl;
806    }
807    if let Some(v) = kvs.get("announce_queue_max_entries") {
808        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
809            key: "announce_queue_max_entries".into(),
810            value: v.clone(),
811        })?;
812        if n == 0 {
813            return Err(ConfigError::InvalidValue {
814                key: "announce_queue_max_entries".into(),
815                value: v.clone(),
816            });
817        }
818        section.announce_queue_max_entries = n;
819    }
820    if let Some(v) = kvs.get("announce_queue_max_interfaces") {
821        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
822            key: "announce_queue_max_interfaces".into(),
823            value: v.clone(),
824        })?;
825        if n == 0 {
826            return Err(ConfigError::InvalidValue {
827                key: "announce_queue_max_interfaces".into(),
828                value: v.clone(),
829            });
830        }
831        section.announce_queue_max_interfaces = n;
832    }
833    if let Some(v) = kvs.get("default_ar_target") {
834        let target = v.parse::<f64>().map_err(|_| ConfigError::InvalidValue {
835            key: "default_ar_target".into(),
836            value: v.clone(),
837        })?;
838        if !target.is_finite() || target < 0.0 {
839            return Err(ConfigError::InvalidValue {
840                key: "default_ar_target".into(),
841                value: v.clone(),
842            });
843        }
844        section.default_ar_target = if target == 0.0 { None } else { Some(target) };
845    }
846    if let Some(v) = kvs.get("default_ar_penalty") {
847        let penalty = v.parse::<f64>().map_err(|_| ConfigError::InvalidValue {
848            key: "default_ar_penalty".into(),
849            value: v.clone(),
850        })?;
851        if !penalty.is_finite() || penalty < 0.0 {
852            return Err(ConfigError::InvalidValue {
853                key: "default_ar_penalty".into(),
854                value: v.clone(),
855            });
856        }
857        section.default_ar_penalty = penalty;
858    }
859    if let Some(v) = kvs.get("default_ar_grace") {
860        let grace = v.parse::<u32>().map_err(|_| ConfigError::InvalidValue {
861            key: "default_ar_grace".into(),
862            value: v.clone(),
863        })?;
864        section.default_ar_grace = grace;
865    }
866    if let Some(v) = parse_usize_option(kvs, "ic_max_held_announces")? {
867        section.default_ic_max_held_announces = v;
868    }
869    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_hold")? {
870        section.default_ic_burst_hold = v;
871    }
872    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_freq_new")? {
873        section.default_ic_burst_freq_new = v;
874    }
875    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_freq")? {
876        section.default_ic_burst_freq = v;
877    }
878    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_pr_burst_freq_new")? {
879        section.default_ic_pr_burst_freq_new = v;
880    }
881    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_pr_burst_freq")? {
882        section.default_ic_pr_burst_freq = v;
883    }
884    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_new_time")? {
885        section.default_ic_new_time = v;
886    }
887    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_penalty")? {
888        section.default_ic_burst_penalty = v;
889    }
890    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_held_release_interval")? {
891        section.default_ic_held_release_interval = v;
892    }
893    if let Some(v) = kvs.get("egress_control") {
894        section.default_egress_control =
895            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
896                key: "egress_control".into(),
897                value: v.clone(),
898            })?;
899    }
900    if let Some(v) = parse_nonnegative_f64_option(kvs, "ec_pr_freq")? {
901        section.default_ec_pr_freq = v;
902    }
903    if let Some(v) = kvs.get("announce_queue_max_bytes") {
904        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
905            key: "announce_queue_max_bytes".into(),
906            value: v.clone(),
907        })?;
908        if n == 0 {
909            return Err(ConfigError::InvalidValue {
910                key: "announce_queue_max_bytes".into(),
911                value: v.clone(),
912            });
913        }
914        section.announce_queue_max_bytes = n;
915    }
916    if let Some(v) = kvs.get("announce_queue_ttl") {
917        let ttl = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
918            key: "announce_queue_ttl".into(),
919            value: v.clone(),
920        })?;
921        if ttl == 0 {
922            return Err(ConfigError::InvalidValue {
923                key: "announce_queue_ttl".into(),
924                value: v.clone(),
925            });
926        }
927        section.announce_queue_ttl = ttl;
928    }
929    if let Some(v) = kvs.get("announce_queue_overflow_policy") {
930        let normalized = v.to_lowercase();
931        if normalized != "drop_newest" && normalized != "drop_oldest" && normalized != "drop_worst"
932        {
933            return Err(ConfigError::InvalidValue {
934                key: "announce_queue_overflow_policy".into(),
935                value: v.clone(),
936            });
937        }
938        section.announce_queue_overflow_policy = normalized;
939    }
940    if let Some(v) = kvs.get("driver_event_queue_capacity") {
941        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
942            key: "driver_event_queue_capacity".into(),
943            value: v.clone(),
944        })?;
945        if n == 0 {
946            return Err(ConfigError::InvalidValue {
947                key: "driver_event_queue_capacity".into(),
948                value: v.clone(),
949            });
950        }
951        section.driver_event_queue_capacity = n;
952    }
953    if let Some(v) = kvs.get("interface_writer_queue_capacity") {
954        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
955            key: "interface_writer_queue_capacity".into(),
956            value: v.clone(),
957        })?;
958        if n == 0 {
959            return Err(ConfigError::InvalidValue {
960                key: "interface_writer_queue_capacity".into(),
961                value: v.clone(),
962            });
963        }
964        section.interface_writer_queue_capacity = n;
965    }
966    if let Some(v) = kvs.get("backbone_peer_pool_max_connected") {
967        section.backbone_peer_pool_max_connected =
968            v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
969                key: "backbone_peer_pool_max_connected".into(),
970                value: v.clone(),
971            })?;
972    }
973    if let Some(v) = kvs.get("backbone_peer_pool_failure_threshold") {
974        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
975            key: "backbone_peer_pool_failure_threshold".into(),
976            value: v.clone(),
977        })?;
978        if n == 0 {
979            return Err(ConfigError::InvalidValue {
980                key: "backbone_peer_pool_failure_threshold".into(),
981                value: v.clone(),
982            });
983        }
984        section.backbone_peer_pool_failure_threshold = n;
985    }
986    if let Some(v) = kvs.get("backbone_peer_pool_failure_window") {
987        section.backbone_peer_pool_failure_window =
988            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
989                key: "backbone_peer_pool_failure_window".into(),
990                value: v.clone(),
991            })?;
992    }
993    if let Some(v) = kvs.get("backbone_peer_pool_cooldown") {
994        section.backbone_peer_pool_cooldown =
995            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
996                key: "backbone_peer_pool_cooldown".into(),
997                value: v.clone(),
998            })?;
999    }
1000    #[cfg(feature = "hooks")]
1001    if let Some(v) = kvs.get("provider_bridge") {
1002        section.provider_bridge = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
1003            key: "provider_bridge".into(),
1004            value: v.clone(),
1005        })?;
1006    }
1007    #[cfg(feature = "hooks")]
1008    if let Some(v) = kvs.get("provider_socket_path") {
1009        section.provider_socket_path = Some(v.clone());
1010    }
1011    #[cfg(feature = "hooks")]
1012    if let Some(v) = kvs.get("provider_queue_max_events") {
1013        section.provider_queue_max_events =
1014            v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1015                key: "provider_queue_max_events".into(),
1016                value: v.clone(),
1017            })?;
1018    }
1019    #[cfg(feature = "hooks")]
1020    if let Some(v) = kvs.get("provider_queue_max_bytes") {
1021        section.provider_queue_max_bytes =
1022            v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1023                key: "provider_queue_max_bytes".into(),
1024                value: v.clone(),
1025            })?;
1026    }
1027    #[cfg(feature = "hooks")]
1028    if let Some(v) = kvs.get("provider_overflow_policy") {
1029        let normalized = v.to_lowercase();
1030        if normalized != "drop_newest" && normalized != "drop_oldest" {
1031            return Err(ConfigError::InvalidValue {
1032                key: "provider_overflow_policy".into(),
1033                value: v.clone(),
1034            });
1035        }
1036        section.provider_overflow_policy = normalized;
1037    }
1038
1039    Ok(section)
1040}
1041
1042fn build_logging_section(kvs: &HashMap<String, String>) -> Result<LoggingSection, ConfigError> {
1043    let mut section = LoggingSection::default();
1044
1045    if let Some(v) = kvs.get("loglevel") {
1046        section.loglevel = v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
1047            key: "loglevel".into(),
1048            value: v.clone(),
1049        })?;
1050    }
1051    if let Some(v) = kvs.get("logtimestamps") {
1052        section.logtimestamps = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
1053            key: "logtimestamps".into(),
1054            value: v.clone(),
1055        })?;
1056    }
1057
1058    Ok(section)
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064
1065    #[test]
1066    fn parse_empty() {
1067        let config = parse("").unwrap();
1068        assert!(!config.reticulum.enable_transport);
1069        assert!(config.reticulum.share_instance);
1070        assert_eq!(config.reticulum.instance_name, "default");
1071        assert_eq!(config.logging.loglevel, 4);
1072        assert!(config.logging.logtimestamps);
1073        assert!(config.interfaces.is_empty());
1074        assert_eq!(
1075            config.reticulum.packet_hashlist_max_entries,
1076            rns_core::constants::HASHLIST_MAXSIZE
1077        );
1078        assert_eq!(
1079            config.reticulum.announce_table_ttl,
1080            rns_core::constants::ANNOUNCE_TABLE_TTL as u64
1081        );
1082        assert_eq!(
1083            config.reticulum.announce_table_max_bytes,
1084            rns_core::constants::ANNOUNCE_TABLE_MAX_BYTES
1085        );
1086        assert_eq!(
1087            config.reticulum.ratchet_expiry,
1088            rns_core::constants::RATCHET_EXPIRY
1089        );
1090    }
1091
1092    #[cfg(feature = "hooks")]
1093    #[test]
1094    fn parse_provider_bridge_config() {
1095        let config = parse(
1096            r#"
1097[reticulum]
1098provider_bridge = yes
1099provider_socket_path = /tmp/rns-provider.sock
1100provider_queue_max_events = 42
1101provider_queue_max_bytes = 8192
1102provider_overflow_policy = drop_oldest
1103"#,
1104        )
1105        .unwrap();
1106
1107        assert!(config.reticulum.provider_bridge);
1108        assert_eq!(
1109            config.reticulum.provider_socket_path.as_deref(),
1110            Some("/tmp/rns-provider.sock")
1111        );
1112        assert_eq!(config.reticulum.provider_queue_max_events, 42);
1113        assert_eq!(config.reticulum.provider_queue_max_bytes, 8192);
1114        assert_eq!(config.reticulum.provider_overflow_policy, "drop_oldest");
1115    }
1116
1117    #[test]
1118    fn parse_default_config() {
1119        // The default config from Python's __default_rns_config__
1120        let input = r#"
1121[reticulum]
1122enable_transport = False
1123share_instance = Yes
1124instance_name = default
1125
1126[logging]
1127loglevel = 4
1128
1129[interfaces]
1130
1131  [[Default Interface]]
1132    type = AutoInterface
1133    enabled = Yes
1134"#;
1135        let config = parse(input).unwrap();
1136        assert!(!config.reticulum.enable_transport);
1137        assert!(config.reticulum.share_instance);
1138        assert_eq!(config.reticulum.instance_name, "default");
1139        assert_eq!(config.logging.loglevel, 4);
1140        assert_eq!(config.interfaces.len(), 1);
1141        assert_eq!(config.interfaces[0].name, "Default Interface");
1142        assert_eq!(config.interfaces[0].interface_type, "AutoInterface");
1143        assert!(config.interfaces[0].enabled);
1144    }
1145
1146    #[test]
1147    fn parse_reticulum_section() {
1148        let input = r#"
1149[reticulum]
1150enable_transport = True
1151share_instance = No
1152instance_name = mynode
1153shared_instance_port = 12345
1154instance_control_port = 12346
1155panic_on_interface_error = Yes
1156use_implicit_proof = False
1157respond_to_probes = True
1158network_identity = /home/user/.reticulum/identity
1159known_destinations_ttl = 1234
1160known_destinations_max_entries = 4321
1161ratchet_expiry = 9876
1162announce_table_ttl = 45
1163announce_table_max_bytes = 65536
1164packet_hashlist_max_entries = 321
1165max_discovery_pr_tags = 222
1166max_path_destinations = 111
1167max_tunnel_destinations_total = 99
1168announce_signature_cache_enabled = false
1169announce_signature_cache_max_entries = 500
1170announce_signature_cache_ttl = 300
1171announce_queue_max_entries = 123
1172announce_queue_max_interfaces = 321
1173announce_queue_max_bytes = 4567
1174announce_queue_ttl = 89
1175announce_queue_overflow_policy = drop_oldest
1176driver_event_queue_capacity = 6543
1177interface_writer_queue_capacity = 210
1178backbone_peer_pool_max_connected = 6
1179backbone_peer_pool_failure_threshold = 4
1180backbone_peer_pool_failure_window = 120
1181backbone_peer_pool_cooldown = 300
1182"#;
1183        let config = parse(input).unwrap();
1184        assert!(config.reticulum.enable_transport);
1185        assert!(!config.reticulum.share_instance);
1186        assert_eq!(config.reticulum.instance_name, "mynode");
1187        assert_eq!(config.reticulum.shared_instance_port, 12345);
1188        assert_eq!(config.reticulum.instance_control_port, 12346);
1189        assert!(config.reticulum.panic_on_interface_error);
1190        assert!(!config.reticulum.use_implicit_proof);
1191        assert!(config.reticulum.respond_to_probes);
1192        assert_eq!(
1193            config.reticulum.network_identity.as_deref(),
1194            Some("/home/user/.reticulum/identity")
1195        );
1196        assert_eq!(config.reticulum.known_destinations_ttl, 1234);
1197        assert_eq!(config.reticulum.known_destinations_max_entries, 4321);
1198        assert_eq!(config.reticulum.ratchet_expiry, 9876);
1199        assert_eq!(config.reticulum.announce_table_ttl, 45);
1200        assert_eq!(config.reticulum.announce_table_max_bytes, 65536);
1201        assert_eq!(config.reticulum.packet_hashlist_max_entries, 321);
1202        assert_eq!(config.reticulum.max_discovery_pr_tags, 222);
1203        assert_eq!(config.reticulum.max_path_destinations, 111);
1204        assert_eq!(config.reticulum.max_tunnel_destinations_total, 99);
1205        assert!(!config.reticulum.announce_sig_cache_enabled);
1206        assert_eq!(config.reticulum.announce_sig_cache_max_entries, 500);
1207        assert_eq!(config.reticulum.announce_sig_cache_ttl, 300);
1208        assert_eq!(config.reticulum.announce_queue_max_entries, 123);
1209        assert_eq!(config.reticulum.announce_queue_max_interfaces, 321);
1210        assert_eq!(config.reticulum.announce_queue_max_bytes, 4567);
1211        assert_eq!(config.reticulum.announce_queue_ttl, 89);
1212        assert_eq!(
1213            config.reticulum.announce_queue_overflow_policy,
1214            "drop_oldest"
1215        );
1216        assert_eq!(config.reticulum.driver_event_queue_capacity, 6543);
1217        assert_eq!(config.reticulum.interface_writer_queue_capacity, 210);
1218        assert_eq!(config.reticulum.backbone_peer_pool_max_connected, 6);
1219        assert_eq!(config.reticulum.backbone_peer_pool_failure_threshold, 4);
1220        assert_eq!(config.reticulum.backbone_peer_pool_failure_window, 120);
1221        assert_eq!(config.reticulum.backbone_peer_pool_cooldown, 300);
1222    }
1223
1224    #[test]
1225    fn parse_reticulum_announce_rate_defaults() {
1226        let input = r#"
1227[reticulum]
1228default_ar_target = 7200
1229default_ar_penalty = 15
1230default_ar_grace = 7
1231"#;
1232        let config = parse(input).unwrap();
1233
1234        assert_eq!(config.reticulum.default_ar_target, Some(7200.0));
1235        assert_eq!(config.reticulum.default_ar_penalty, 15.0);
1236        assert_eq!(config.reticulum.default_ar_grace, 7);
1237    }
1238
1239    #[test]
1240    fn parse_reticulum_announce_rate_target_zero_disables_default() {
1241        let input = r#"
1242[reticulum]
1243default_ar_target = 0
1244default_ar_penalty = 0
1245default_ar_grace = 0
1246"#;
1247        let config = parse(input).unwrap();
1248
1249        assert_eq!(config.reticulum.default_ar_target, None);
1250        assert_eq!(config.reticulum.default_ar_penalty, 0.0);
1251        assert_eq!(config.reticulum.default_ar_grace, 0);
1252    }
1253
1254    #[test]
1255    fn parse_reticulum_announce_rate_defaults_reject_negative_values() {
1256        for (key, value) in [
1257            ("default_ar_target", "-1"),
1258            ("default_ar_target", "NaN"),
1259            ("default_ar_target", "inf"),
1260            ("default_ar_penalty", "-1"),
1261            ("default_ar_penalty", "NaN"),
1262            ("default_ar_penalty", "inf"),
1263            ("default_ar_grace", "-1"),
1264        ] {
1265            let input = format!("[reticulum]\n{key} = {value}\n");
1266            let err = parse(&input).unwrap_err();
1267            assert!(
1268                err.to_string().contains(key),
1269                "error {err:?} should mention {key}"
1270            );
1271        }
1272    }
1273
1274    #[test]
1275    fn parse_reticulum_ingress_and_egress_control_defaults() {
1276        let input = r#"[reticulum]
1277ic_max_held_announces = 17
1278ic_burst_hold = 1.5
1279ic_burst_freq_new = 2.5
1280ic_burst_freq = 3.5
1281ic_pr_burst_freq_new = 4.5
1282ic_pr_burst_freq = 5.5
1283ic_new_time = 6.5
1284ic_burst_penalty = 7.5
1285ic_held_release_interval = 8.5
1286egress_control = Yes
1287ec_pr_freq = 9.5
1288"#;
1289
1290        let config = parse(input).unwrap();
1291
1292        assert_eq!(config.reticulum.default_ic_max_held_announces, 17);
1293        assert_eq!(config.reticulum.default_ic_burst_hold, 1.5);
1294        assert_eq!(config.reticulum.default_ic_burst_freq_new, 2.5);
1295        assert_eq!(config.reticulum.default_ic_burst_freq, 3.5);
1296        assert_eq!(config.reticulum.default_ic_pr_burst_freq_new, 4.5);
1297        assert_eq!(config.reticulum.default_ic_pr_burst_freq, 5.5);
1298        assert_eq!(config.reticulum.default_ic_new_time, 6.5);
1299        assert_eq!(config.reticulum.default_ic_burst_penalty, 7.5);
1300        assert_eq!(config.reticulum.default_ic_held_release_interval, 8.5);
1301        assert!(config.reticulum.default_egress_control);
1302        assert_eq!(config.reticulum.default_ec_pr_freq, 9.5);
1303    }
1304
1305    #[test]
1306    fn parse_reticulum_ingress_and_egress_defaults_reject_invalid_values() {
1307        for (key, value) in [
1308            ("ic_max_held_announces", "-1"),
1309            ("ic_burst_hold", "-1"),
1310            ("ic_burst_hold", "NaN"),
1311            ("ic_burst_freq_new", "-1"),
1312            ("ic_burst_freq", "inf"),
1313            ("ic_pr_burst_freq_new", "-1"),
1314            ("ic_pr_burst_freq", "NaN"),
1315            ("ic_new_time", "-1"),
1316            ("ic_burst_penalty", "-1"),
1317            ("ic_held_release_interval", "-1"),
1318            ("ec_pr_freq", "-1"),
1319            ("ec_pr_freq", "inf"),
1320        ] {
1321            let input = format!("[reticulum]\n{key} = {value}\n");
1322            let err = parse(&input).unwrap_err();
1323            assert!(
1324                format!("{err}").contains(key),
1325                "error {err} should mention {key}"
1326            );
1327        }
1328    }
1329
1330    #[test]
1331    fn parse_backbone_peer_pool_defaults_disabled() {
1332        let config = parse("[reticulum]\n").unwrap();
1333        assert_eq!(config.reticulum.backbone_peer_pool_max_connected, 0);
1334        assert_eq!(config.reticulum.backbone_peer_pool_failure_threshold, 3);
1335        assert_eq!(config.reticulum.backbone_peer_pool_failure_window, 600);
1336        assert_eq!(config.reticulum.backbone_peer_pool_cooldown, 900);
1337    }
1338
1339    #[test]
1340    fn parse_announce_table_limits_reject_zero() {
1341        let err = parse(
1342            r#"
1343[reticulum]
1344announce_table_ttl = 0
1345"#,
1346        )
1347        .unwrap_err();
1348        assert!(matches!(
1349            err,
1350            ConfigError::InvalidValue { key, .. } if key == "announce_table_ttl"
1351        ));
1352
1353        let err = parse(
1354            r#"
1355[reticulum]
1356known_destinations_max_entries = 0
1357"#,
1358        )
1359        .unwrap_err();
1360        assert!(matches!(
1361            err,
1362            ConfigError::InvalidValue { key, .. } if key == "known_destinations_max_entries"
1363        ));
1364
1365        let err = parse(
1366            r#"
1367[reticulum]
1368ratchet_expiry = 0
1369"#,
1370        )
1371        .unwrap_err();
1372        assert!(matches!(
1373            err,
1374            ConfigError::InvalidValue { key, .. } if key == "ratchet_expiry"
1375        ));
1376
1377        let err = parse(
1378            r#"
1379[reticulum]
1380announce_table_max_bytes = 0
1381"#,
1382        )
1383        .unwrap_err();
1384        assert!(matches!(
1385            err,
1386            ConfigError::InvalidValue { key, .. } if key == "announce_table_max_bytes"
1387        ));
1388
1389        let err = parse(
1390            r#"
1391[reticulum]
1392announce_queue_max_entries = 0
1393"#,
1394        )
1395        .unwrap_err();
1396        assert!(matches!(
1397            err,
1398            ConfigError::InvalidValue { key, .. } if key == "announce_queue_max_entries"
1399        ));
1400
1401        let err = parse(
1402            r#"
1403[reticulum]
1404announce_queue_max_interfaces = 0
1405"#,
1406        )
1407        .unwrap_err();
1408        assert!(matches!(
1409            err,
1410            ConfigError::InvalidValue { key, .. } if key == "announce_queue_max_interfaces"
1411        ));
1412
1413        let err = parse(
1414            r#"
1415[reticulum]
1416announce_queue_max_bytes = 0
1417"#,
1418        )
1419        .unwrap_err();
1420        assert!(matches!(
1421            err,
1422            ConfigError::InvalidValue { key, .. } if key == "announce_queue_max_bytes"
1423        ));
1424
1425        let err = parse(
1426            r#"
1427[reticulum]
1428driver_event_queue_capacity = 0
1429"#,
1430        )
1431        .unwrap_err();
1432        assert!(matches!(
1433            err,
1434            ConfigError::InvalidValue { key, .. } if key == "driver_event_queue_capacity"
1435        ));
1436
1437        let err = parse(
1438            r#"
1439[reticulum]
1440interface_writer_queue_capacity = 0
1441"#,
1442        )
1443        .unwrap_err();
1444        assert!(matches!(
1445            err,
1446            ConfigError::InvalidValue { key, .. } if key == "interface_writer_queue_capacity"
1447        ));
1448
1449        let err = parse(
1450            r#"
1451[reticulum]
1452announce_queue_ttl = 0
1453"#,
1454        )
1455        .unwrap_err();
1456        assert!(matches!(
1457            err,
1458            ConfigError::InvalidValue { key, .. } if key == "announce_queue_ttl"
1459        ));
1460    }
1461
1462    #[test]
1463    fn parse_announce_queue_overflow_policy_rejects_invalid() {
1464        let err = parse(
1465            r#"
1466[reticulum]
1467announce_queue_overflow_policy = keep_everything
1468"#,
1469        )
1470        .unwrap_err();
1471        assert!(matches!(
1472            err,
1473            ConfigError::InvalidValue { key, .. } if key == "announce_queue_overflow_policy"
1474        ));
1475    }
1476
1477    #[test]
1478    fn parse_destination_timeout_secs_alias() {
1479        let config = parse(
1480            r#"
1481[reticulum]
1482destination_timeout_secs = 777
1483"#,
1484        )
1485        .unwrap();
1486
1487        assert_eq!(config.reticulum.known_destinations_ttl, 777);
1488    }
1489
1490    #[test]
1491    fn parse_logging_section() {
1492        let input = "[logging]\nloglevel = 6\nlogtimestamps = no\n";
1493        let config = parse(input).unwrap();
1494        assert_eq!(config.logging.loglevel, 6);
1495        assert!(!config.logging.logtimestamps);
1496    }
1497
1498    #[test]
1499    fn parse_logging_rejects_invalid_logtimestamps() {
1500        let err = parse("[logging]\nlogtimestamps = maybe\n").unwrap_err();
1501        assert!(matches!(
1502            err,
1503            ConfigError::InvalidValue { key, .. } if key == "logtimestamps"
1504        ));
1505    }
1506
1507    #[test]
1508    fn parse_interface_tcp_client() {
1509        let input = r#"
1510[interfaces]
1511  [[TCP Client]]
1512    type = TCPClientInterface
1513    enabled = Yes
1514    target_host = 87.106.8.245
1515    target_port = 4242
1516"#;
1517        let config = parse(input).unwrap();
1518        assert_eq!(config.interfaces.len(), 1);
1519        let iface = &config.interfaces[0];
1520        assert_eq!(iface.name, "TCP Client");
1521        assert_eq!(iface.interface_type, "TCPClientInterface");
1522        assert!(iface.enabled);
1523        assert_eq!(iface.params.get("target_host").unwrap(), "87.106.8.245");
1524        assert_eq!(iface.params.get("target_port").unwrap(), "4242");
1525    }
1526
1527    #[test]
1528    fn parse_interface_tcp_server() {
1529        let input = r#"
1530[interfaces]
1531  [[TCP Server]]
1532    type = TCPServerInterface
1533    enabled = Yes
1534    listen_ip = 0.0.0.0
1535    listen_port = 4242
1536"#;
1537        let config = parse(input).unwrap();
1538        assert_eq!(config.interfaces.len(), 1);
1539        let iface = &config.interfaces[0];
1540        assert_eq!(iface.name, "TCP Server");
1541        assert_eq!(iface.interface_type, "TCPServerInterface");
1542        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
1543        assert_eq!(iface.params.get("listen_port").unwrap(), "4242");
1544    }
1545
1546    #[test]
1547    fn parse_interface_udp() {
1548        let input = r#"
1549[interfaces]
1550  [[UDP Interface]]
1551    type = UDPInterface
1552    enabled = Yes
1553    listen_ip = 0.0.0.0
1554    listen_port = 4242
1555    forward_ip = 255.255.255.255
1556    forward_port = 4242
1557"#;
1558        let config = parse(input).unwrap();
1559        assert_eq!(config.interfaces.len(), 1);
1560        let iface = &config.interfaces[0];
1561        assert_eq!(iface.name, "UDP Interface");
1562        assert_eq!(iface.interface_type, "UDPInterface");
1563        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
1564        assert_eq!(iface.params.get("forward_ip").unwrap(), "255.255.255.255");
1565    }
1566
1567    #[test]
1568    fn parse_multiple_interfaces() {
1569        let input = r#"
1570[interfaces]
1571  [[TCP Client]]
1572    type = TCPClientInterface
1573    target_host = 10.0.0.1
1574    target_port = 4242
1575
1576  [[UDP Broadcast]]
1577    type = UDPInterface
1578    listen_ip = 0.0.0.0
1579    listen_port = 5555
1580    forward_ip = 255.255.255.255
1581    forward_port = 5555
1582"#;
1583        let config = parse(input).unwrap();
1584        assert_eq!(config.interfaces.len(), 2);
1585        assert_eq!(config.interfaces[0].name, "TCP Client");
1586        assert_eq!(config.interfaces[0].interface_type, "TCPClientInterface");
1587        assert_eq!(config.interfaces[1].name, "UDP Broadcast");
1588        assert_eq!(config.interfaces[1].interface_type, "UDPInterface");
1589    }
1590
1591    #[test]
1592    fn parse_booleans() {
1593        // Test all boolean variants
1594        for (input, expected) in &[
1595            ("Yes", true),
1596            ("No", false),
1597            ("True", true),
1598            ("False", false),
1599            ("true", true),
1600            ("false", false),
1601            ("1", true),
1602            ("0", false),
1603            ("on", true),
1604            ("off", false),
1605        ] {
1606            let result = parse_bool(input);
1607            assert_eq!(result, Some(*expected), "parse_bool({}) failed", input);
1608        }
1609    }
1610
1611    #[test]
1612    fn parse_comments() {
1613        let input = r#"
1614# This is a comment
1615[reticulum]
1616enable_transport = True  # inline comment
1617# share_instance = No
1618instance_name = test
1619"#;
1620        let config = parse(input).unwrap();
1621        assert!(config.reticulum.enable_transport);
1622        assert!(config.reticulum.share_instance); // commented out line should be ignored
1623        assert_eq!(config.reticulum.instance_name, "test");
1624    }
1625
1626    #[test]
1627    fn parse_interface_mode_field() {
1628        let input = r#"
1629[interfaces]
1630  [[TCP Client]]
1631    type = TCPClientInterface
1632    interface_mode = access_point
1633    target_host = 10.0.0.1
1634    target_port = 4242
1635"#;
1636        let config = parse(input).unwrap();
1637        assert_eq!(config.interfaces[0].mode, "access_point");
1638    }
1639
1640    #[test]
1641    fn parse_mode_fallback() {
1642        // Python also accepts "mode" as fallback for "interface_mode"
1643        let input = r#"
1644[interfaces]
1645  [[TCP Client]]
1646    type = TCPClientInterface
1647    mode = gateway
1648    target_host = 10.0.0.1
1649    target_port = 4242
1650"#;
1651        let config = parse(input).unwrap();
1652        assert_eq!(config.interfaces[0].mode, "gateway");
1653    }
1654
1655    #[test]
1656    fn parse_interface_mode_takes_precedence() {
1657        // If both interface_mode and mode are set, interface_mode wins
1658        let input = r#"
1659[interfaces]
1660  [[TCP Client]]
1661    type = TCPClientInterface
1662    interface_mode = roaming
1663    mode = boundary
1664    target_host = 10.0.0.1
1665    target_port = 4242
1666"#;
1667        let config = parse(input).unwrap();
1668        assert_eq!(config.interfaces[0].mode, "roaming");
1669    }
1670
1671    #[test]
1672    fn parse_disabled_interface() {
1673        let input = r#"
1674[interfaces]
1675  [[Disabled TCP]]
1676    type = TCPClientInterface
1677    enabled = No
1678    target_host = 10.0.0.1
1679    target_port = 4242
1680"#;
1681        let config = parse(input).unwrap();
1682        assert_eq!(config.interfaces.len(), 1);
1683        assert!(!config.interfaces[0].enabled);
1684    }
1685
1686    #[test]
1687    fn parse_serial_interface() {
1688        let input = r#"
1689[interfaces]
1690  [[Serial Port]]
1691    type = SerialInterface
1692    enabled = Yes
1693    port = /dev/ttyUSB0
1694    speed = 115200
1695    databits = 8
1696    parity = N
1697    stopbits = 1
1698"#;
1699        let config = parse(input).unwrap();
1700        assert_eq!(config.interfaces.len(), 1);
1701        let iface = &config.interfaces[0];
1702        assert_eq!(iface.name, "Serial Port");
1703        assert_eq!(iface.interface_type, "SerialInterface");
1704        assert!(iface.enabled);
1705        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB0");
1706        assert_eq!(iface.params.get("speed").unwrap(), "115200");
1707        assert_eq!(iface.params.get("databits").unwrap(), "8");
1708        assert_eq!(iface.params.get("parity").unwrap(), "N");
1709        assert_eq!(iface.params.get("stopbits").unwrap(), "1");
1710    }
1711
1712    #[test]
1713    fn parse_kiss_interface() {
1714        let input = r#"
1715[interfaces]
1716  [[KISS TNC]]
1717    type = KISSInterface
1718    enabled = Yes
1719    port = /dev/ttyUSB1
1720    speed = 9600
1721    preamble = 350
1722    txtail = 20
1723    persistence = 64
1724    slottime = 20
1725    flow_control = True
1726    id_interval = 600
1727    id_callsign = MYCALL
1728"#;
1729        let config = parse(input).unwrap();
1730        assert_eq!(config.interfaces.len(), 1);
1731        let iface = &config.interfaces[0];
1732        assert_eq!(iface.name, "KISS TNC");
1733        assert_eq!(iface.interface_type, "KISSInterface");
1734        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB1");
1735        assert_eq!(iface.params.get("speed").unwrap(), "9600");
1736        assert_eq!(iface.params.get("preamble").unwrap(), "350");
1737        assert_eq!(iface.params.get("txtail").unwrap(), "20");
1738        assert_eq!(iface.params.get("persistence").unwrap(), "64");
1739        assert_eq!(iface.params.get("slottime").unwrap(), "20");
1740        assert_eq!(iface.params.get("flow_control").unwrap(), "True");
1741        assert_eq!(iface.params.get("id_interval").unwrap(), "600");
1742        assert_eq!(iface.params.get("id_callsign").unwrap(), "MYCALL");
1743    }
1744
1745    #[test]
1746    fn parse_ifac_networkname() {
1747        let input = r#"
1748[interfaces]
1749  [[TCP Client]]
1750    type = TCPClientInterface
1751    target_host = 10.0.0.1
1752    target_port = 4242
1753    networkname = testnet
1754"#;
1755        let config = parse(input).unwrap();
1756        assert_eq!(
1757            config.interfaces[0].params.get("networkname").unwrap(),
1758            "testnet"
1759        );
1760    }
1761
1762    #[test]
1763    fn parse_ifac_passphrase() {
1764        let input = r#"
1765[interfaces]
1766  [[TCP Client]]
1767    type = TCPClientInterface
1768    target_host = 10.0.0.1
1769    target_port = 4242
1770    passphrase = secret123
1771    ifac_size = 64
1772"#;
1773        let config = parse(input).unwrap();
1774        assert_eq!(
1775            config.interfaces[0].params.get("passphrase").unwrap(),
1776            "secret123"
1777        );
1778        assert_eq!(config.interfaces[0].params.get("ifac_size").unwrap(), "64");
1779    }
1780
1781    #[test]
1782    fn parse_remote_management_config() {
1783        let input = r#"
1784[reticulum]
1785enable_transport = True
1786enable_remote_management = Yes
1787remote_management_allowed = aabbccdd00112233aabbccdd00112233, 11223344556677881122334455667788
1788publish_blackhole = Yes
1789"#;
1790        let config = parse(input).unwrap();
1791        assert!(config.reticulum.enable_remote_management);
1792        assert!(config.reticulum.publish_blackhole);
1793        assert_eq!(config.reticulum.remote_management_allowed.len(), 2);
1794        assert_eq!(
1795            config.reticulum.remote_management_allowed[0],
1796            "aabbccdd00112233aabbccdd00112233"
1797        );
1798        assert_eq!(
1799            config.reticulum.remote_management_allowed[1],
1800            "11223344556677881122334455667788"
1801        );
1802    }
1803
1804    #[test]
1805    fn parse_remote_management_defaults() {
1806        let input = "[reticulum]\n";
1807        let config = parse(input).unwrap();
1808        assert!(!config.reticulum.enable_remote_management);
1809        assert!(!config.reticulum.publish_blackhole);
1810        assert!(config.reticulum.remote_management_allowed.is_empty());
1811    }
1812
1813    #[test]
1814    fn parse_hooks_section() {
1815        let input = r#"
1816[hooks]
1817  [[drop_tick]]
1818    path = /tmp/drop_tick.wasm
1819    attach_point = Tick
1820    priority = 10
1821    enabled = Yes
1822
1823  [[log_announce]]
1824    path = /tmp/log_announce.wasm
1825    type = native
1826    attach_point = AnnounceReceived
1827    priority = 5
1828    enabled = No
1829
1830  [[builtin_tick]]
1831    builtin = example.tick
1832    type = builtin
1833    attach_point = Tick
1834"#;
1835        let config = parse(input).unwrap();
1836        assert_eq!(config.hooks.len(), 3);
1837        assert_eq!(config.hooks[0].name, "drop_tick");
1838        assert_eq!(config.hooks[0].path, "/tmp/drop_tick.wasm");
1839        assert_eq!(config.hooks[0].hook_type, default_hook_type());
1840        assert_eq!(config.hooks[0].attach_point, "Tick");
1841        assert_eq!(config.hooks[0].priority, 10);
1842        assert!(config.hooks[0].enabled);
1843        assert_eq!(config.hooks[1].name, "log_announce");
1844        assert_eq!(config.hooks[1].hook_type, "native");
1845        assert_eq!(config.hooks[1].attach_point, "AnnounceReceived");
1846        assert!(!config.hooks[1].enabled);
1847        assert_eq!(config.hooks[2].hook_type, "builtin");
1848        assert_eq!(config.hooks[2].builtin_id.as_deref(), Some("example.tick"));
1849    }
1850
1851    #[test]
1852    fn parse_empty_hooks() {
1853        let input = "[hooks]\n";
1854        let config = parse(input).unwrap();
1855        assert!(config.hooks.is_empty());
1856    }
1857
1858    #[test]
1859    fn parse_hook_point_names() {
1860        assert_eq!(parse_hook_point("PreIngress"), Some(0));
1861        assert_eq!(parse_hook_point("PreDispatch"), Some(1));
1862        assert_eq!(parse_hook_point("AnnounceReceived"), Some(2));
1863        assert_eq!(parse_hook_point("PathUpdated"), Some(3));
1864        assert_eq!(parse_hook_point("AnnounceRetransmit"), Some(4));
1865        assert_eq!(parse_hook_point("LinkRequestReceived"), Some(5));
1866        assert_eq!(parse_hook_point("LinkEstablished"), Some(6));
1867        assert_eq!(parse_hook_point("LinkClosed"), Some(7));
1868        assert_eq!(parse_hook_point("InterfaceUp"), Some(8));
1869        assert_eq!(parse_hook_point("InterfaceDown"), Some(9));
1870        assert_eq!(parse_hook_point("InterfaceConfigChanged"), Some(10));
1871        assert_eq!(parse_hook_point("BackbonePeerConnected"), Some(11));
1872        assert_eq!(parse_hook_point("BackbonePeerDisconnected"), Some(12));
1873        assert_eq!(parse_hook_point("BackbonePeerIdleTimeout"), Some(13));
1874        assert_eq!(parse_hook_point("BackbonePeerWriteStall"), Some(14));
1875        assert_eq!(parse_hook_point("BackbonePeerPenalty"), Some(15));
1876        assert_eq!(parse_hook_point("SendOnInterface"), Some(16));
1877        assert_eq!(parse_hook_point("BroadcastOnAllInterfaces"), Some(17));
1878        assert_eq!(parse_hook_point("DeliverLocal"), Some(18));
1879        assert_eq!(parse_hook_point("TunnelSynthesize"), Some(19));
1880        assert_eq!(parse_hook_point("Tick"), Some(20));
1881        assert_eq!(parse_hook_point("Unknown"), None);
1882    }
1883
1884    #[test]
1885    fn backbone_extra_params_preserved() {
1886        let config = r#"
1887[reticulum]
1888enable_transport = True
1889
1890[interfaces]
1891  [[Public Entrypoint]]
1892    type = BackboneInterface
1893    enabled = yes
1894    listen_ip = 0.0.0.0
1895    listen_port = 4242
1896    interface_mode = gateway
1897    discoverable = Yes
1898    discovery_name = PizzaSpaghettiMandolino
1899    announce_interval = 600
1900    discovery_stamp_value = 24
1901    reachable_on = 87.106.8.245
1902"#;
1903        let parsed = parse(config).unwrap();
1904        assert_eq!(parsed.interfaces.len(), 1);
1905        let iface = &parsed.interfaces[0];
1906        assert_eq!(iface.name, "Public Entrypoint");
1907        assert_eq!(iface.interface_type, "BackboneInterface");
1908        // After removing type, enabled, interface_mode, remaining params should include discovery keys
1909        assert_eq!(
1910            iface.params.get("discoverable").map(|s| s.as_str()),
1911            Some("Yes")
1912        );
1913        assert_eq!(
1914            iface.params.get("discovery_name").map(|s| s.as_str()),
1915            Some("PizzaSpaghettiMandolino")
1916        );
1917        assert_eq!(
1918            iface.params.get("announce_interval").map(|s| s.as_str()),
1919            Some("600")
1920        );
1921        assert_eq!(
1922            iface
1923                .params
1924                .get("discovery_stamp_value")
1925                .map(|s| s.as_str()),
1926            Some("24")
1927        );
1928        assert_eq!(
1929            iface.params.get("reachable_on").map(|s| s.as_str()),
1930            Some("87.106.8.245")
1931        );
1932        assert_eq!(
1933            iface.params.get("listen_ip").map(|s| s.as_str()),
1934            Some("0.0.0.0")
1935        );
1936        assert_eq!(
1937            iface.params.get("listen_port").map(|s| s.as_str()),
1938            Some("4242")
1939        );
1940    }
1941
1942    #[test]
1943    fn parse_probe_protocol() {
1944        let input = r#"
1945[reticulum]
1946probe_addr = 1.2.3.4:19302
1947probe_protocol = stun
1948"#;
1949        let config = parse(input).unwrap();
1950        assert_eq!(
1951            config.reticulum.probe_addr.as_deref(),
1952            Some("1.2.3.4:19302")
1953        );
1954        assert_eq!(config.reticulum.probe_protocol.as_deref(), Some("stun"));
1955    }
1956
1957    #[test]
1958    fn parse_probe_protocol_defaults_to_none() {
1959        let input = r#"
1960[reticulum]
1961probe_addr = 1.2.3.4:4343
1962"#;
1963        let config = parse(input).unwrap();
1964        assert_eq!(config.reticulum.probe_addr.as_deref(), Some("1.2.3.4:4343"));
1965        assert!(config.reticulum.probe_protocol.is_none());
1966    }
1967}