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