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 static_transport_identity: bool,
38    pub local_hops_delta: bool,
39    pub share_instance: bool,
40    /// Allow local shared-instance clients to attach, detach, and reload interfaces.
41    pub enable_interface_management: bool,
42    pub instance_name: String,
43    pub shared_instance_port: u16,
44    pub instance_control_port: u16,
45    pub panic_on_interface_error: bool,
46    pub use_implicit_proof: bool,
47    pub network_identity: Option<String>,
48    pub respond_to_probes: bool,
49    pub enable_remote_management: bool,
50    pub remote_management_allowed: Vec<String>,
51    pub publish_blackhole: bool,
52    pub probe_port: Option<u16>,
53    pub probe_addr: Option<String>,
54    /// Protocol for endpoint discovery: "rnsp" (default) or "stun".
55    pub probe_protocol: Option<String>,
56    /// Policy for incoming direct-connect proposals.
57    pub direct_connect_policy: crate::event::HolePunchPolicy,
58    /// Accept direct-connect endpoints in private, shared, link-local,
59    /// and unique-local ranges (LAN-only deployments).
60    pub direct_connect_allow_private_endpoints: bool,
61    /// Network interface to bind outbound sockets to (e.g. "usb0").
62    pub device: Option<String>,
63    /// Linux `SO_MARK` value applied to Reticulum IP underlay sockets.
64    pub underlay_mark: Option<u32>,
65    /// Enable interface discovery (advertise discoverable interfaces and
66    /// listen for discovery announces from the network).
67    pub discover_interfaces: bool,
68    /// Global gravity used by interfaces without an explicit override.
69    pub default_gravity: i64,
70    /// Mode assigned to auto-connected interfaces.
71    pub autoconnect_interface_mode: Option<String>,
72    /// Gravity assigned to auto-connected interfaces.
73    pub autoconnect_interface_gravity: i64,
74    /// Allow auto-connected interfaces to propagate announces to internal interfaces.
75    pub autoconnect_announces_to_internal: bool,
76    /// Allow auto-connecting discovered interfaces from unverified implementations.
77    pub autoconnect_unverified_implementations: bool,
78    /// Minimum stamp value for accepting discovered interfaces.
79    pub required_discovery_value: Option<u8>,
80    /// Accept an announce with strictly fewer hops even when the random_blob
81    /// is a duplicate of the existing path entry.
82    pub prefer_shorter_path: bool,
83    /// Maximum number of alternative paths stored per destination.
84    /// Default 1 (single path, backward-compatible).
85    pub max_paths_per_destination: usize,
86    /// Maximum number of packet hashes retained for duplicate suppression.
87    pub packet_hashlist_max_entries: usize,
88    /// Startup allocation policy for the packet hashlist payload pages.
89    pub packet_hashlist_allocation: rns_core::transport::types::PacketHashlistAllocation,
90    /// Maximum number of discovery path-request tags remembered.
91    pub max_discovery_pr_tags: usize,
92    /// Maximum number of destinations retained in the live path table.
93    pub max_path_destinations: usize,
94    /// Maximum number of destinations retained across tunnel-known paths.
95    pub max_tunnel_destinations_total: usize,
96    /// TTL for recalled known destinations without an active path, in seconds.
97    pub known_destinations_ttl: u64,
98    /// Maximum number of recalled known destinations retained.
99    pub known_destinations_max_entries: usize,
100    /// TTL for received ratchets, in seconds.
101    pub ratchet_expiry: u64,
102    /// TTL for announce retransmission state, in seconds.
103    pub announce_table_ttl: u64,
104    /// Maximum retained bytes for announce retransmission state.
105    pub announce_table_max_bytes: usize,
106    /// Whether the announce signature verification cache is enabled.
107    pub announce_sig_cache_enabled: bool,
108    /// Maximum entries in the announce signature verification cache.
109    pub announce_sig_cache_max_entries: usize,
110    /// TTL for announce signature cache entries, in seconds.
111    pub announce_sig_cache_ttl: u64,
112    /// Maximum entries in the async announce verification queue.
113    pub announce_queue_max_entries: usize,
114    /// Maximum interface-scoped announce queues retained.
115    pub announce_queue_max_interfaces: usize,
116    /// Default announce-rate target for transport-node interfaces, in seconds.
117    pub default_ar_target: Option<f64>,
118    /// Default announce-rate penalty for transport-node interfaces, in seconds.
119    pub default_ar_penalty: f64,
120    /// Default announce-rate grace count for transport-node interfaces.
121    pub default_ar_grace: u32,
122    /// Default maximum held announces for ingress control.
123    pub default_ic_max_held_announces: usize,
124    /// Default announce burst hold time for ingress control.
125    pub default_ic_burst_hold: f64,
126    /// Default new-interface announce burst threshold.
127    pub default_ic_burst_freq_new: f64,
128    /// Default mature-interface announce burst threshold.
129    pub default_ic_burst_freq: f64,
130    /// Default new-interface path request burst threshold.
131    pub default_ic_pr_burst_freq_new: f64,
132    /// Default mature-interface path request burst threshold.
133    pub default_ic_pr_burst_freq: f64,
134    /// Default new-interface age window for ingress control.
135    pub default_ic_new_time: f64,
136    /// Default burst penalty before releasing held announces.
137    pub default_ic_burst_penalty: f64,
138    /// Default interval between released held announces.
139    pub default_ic_held_release_interval: f64,
140    /// Default egress path request limiting state.
141    pub default_egress_control: bool,
142    /// Default egress path request frequency threshold.
143    pub default_ec_pr_freq: f64,
144    /// Maximum retained bytes in the async announce verification queue.
145    pub announce_queue_max_bytes: usize,
146    /// TTL for queued async announce verification entries, in seconds.
147    pub announce_queue_ttl: u64,
148    /// Overflow policy for the async announce verification queue.
149    pub announce_queue_overflow_policy: String,
150    /// Maximum queued events awaiting driver processing.
151    pub driver_event_queue_capacity: usize,
152    /// Independently bounded inbound traffic-class queues.
153    pub inbound_queue_capacities: crate::event::InboundQueueCapacities,
154    /// Maximum queued outbound frames per interface writer worker.
155    pub interface_writer_queue_capacity: usize,
156    /// Maximum active outbound Backbone peer-pool connections. Zero disables pooling.
157    pub backbone_peer_pool_max_connected: usize,
158    /// Failures within the failure window before a discovered pooled Backbone
159    /// peer enters cooldown. Configured peers never enter cooldown: they retry
160    /// with the Backbone client backoff (reconnect wait doubled per failure,
161    /// capped at 64x, i.e. 320 s by default, with ±25 % jitter).
162    pub backbone_peer_pool_failure_threshold: usize,
163    /// Failure accounting window for pooled Backbone peers, in seconds.
164    pub backbone_peer_pool_failure_window: u64,
165    /// Cooldown duration for failed discovered pooled Backbone peers, in seconds.
166    pub backbone_peer_pool_cooldown: u64,
167    #[cfg(feature = "hooks")]
168    pub provider_bridge: bool,
169    #[cfg(feature = "hooks")]
170    pub provider_socket_path: Option<String>,
171    #[cfg(feature = "hooks")]
172    pub provider_queue_max_events: usize,
173    #[cfg(feature = "hooks")]
174    pub provider_queue_max_bytes: usize,
175    #[cfg(feature = "hooks")]
176    pub provider_overflow_policy: String,
177}
178
179impl Default for ReticulumSection {
180    fn default() -> Self {
181        ReticulumSection {
182            enable_transport: false,
183            static_transport_identity: false,
184            local_hops_delta: false,
185            share_instance: true,
186            enable_interface_management: true,
187            instance_name: "default".into(),
188            shared_instance_port: 37428,
189            instance_control_port: 37429,
190            panic_on_interface_error: false,
191            use_implicit_proof: true,
192            network_identity: None,
193            respond_to_probes: false,
194            enable_remote_management: false,
195            remote_management_allowed: Vec::new(),
196            publish_blackhole: false,
197            probe_port: None,
198            probe_addr: None,
199            probe_protocol: None,
200            direct_connect_policy: crate::event::HolePunchPolicy::IdentifiedOnly,
201            direct_connect_allow_private_endpoints: false,
202            device: None,
203            underlay_mark: None,
204            discover_interfaces: false,
205            default_gravity: 0,
206            autoconnect_interface_mode: None,
207            autoconnect_interface_gravity: 0,
208            autoconnect_announces_to_internal: false,
209            autoconnect_unverified_implementations: false,
210            required_discovery_value: None,
211            prefer_shorter_path: false,
212            max_paths_per_destination: 1,
213            packet_hashlist_max_entries: rns_core::constants::HASHLIST_MAXSIZE,
214            packet_hashlist_allocation: rns_core::transport::types::PacketHashlistAllocation::Eager,
215            max_discovery_pr_tags: rns_core::constants::MAX_PR_TAGS,
216            max_path_destinations: rns_core::transport::types::DEFAULT_MAX_PATH_DESTINATIONS,
217            max_tunnel_destinations_total: usize::MAX,
218            known_destinations_ttl: 48 * 60 * 60,
219            known_destinations_max_entries: 8192,
220            ratchet_expiry: rns_core::constants::RATCHET_EXPIRY,
221            announce_table_ttl: rns_core::constants::ANNOUNCE_TABLE_TTL as u64,
222            announce_table_max_bytes: rns_core::constants::ANNOUNCE_TABLE_MAX_BYTES,
223            announce_sig_cache_enabled: true,
224            announce_sig_cache_max_entries: rns_core::constants::ANNOUNCE_SIG_CACHE_MAXSIZE,
225            announce_sig_cache_ttl: rns_core::constants::ANNOUNCE_SIG_CACHE_TTL as u64,
226            announce_queue_max_entries: 256,
227            announce_queue_max_interfaces: 1024,
228            default_ar_target: Some(3600.0),
229            default_ar_penalty: 0.0,
230            default_ar_grace: 5,
231            default_ic_max_held_announces: rns_core::constants::IC_MAX_HELD_ANNOUNCES,
232            default_ic_burst_hold: rns_core::constants::IC_BURST_HOLD,
233            default_ic_burst_freq_new: rns_core::constants::IC_BURST_FREQ_NEW,
234            default_ic_burst_freq: rns_core::constants::IC_BURST_FREQ,
235            default_ic_pr_burst_freq_new: rns_core::constants::IC_PR_BURST_FREQ_NEW,
236            default_ic_pr_burst_freq: rns_core::constants::IC_PR_BURST_FREQ,
237            default_ic_new_time: rns_core::constants::IC_NEW_TIME,
238            default_ic_burst_penalty: rns_core::constants::IC_BURST_PENALTY,
239            default_ic_held_release_interval: rns_core::constants::IC_HELD_RELEASE_INTERVAL,
240            default_egress_control: false,
241            default_ec_pr_freq: rns_core::constants::EC_PR_FREQ,
242            announce_queue_max_bytes: 256 * 1024,
243            announce_queue_ttl: 30,
244            announce_queue_overflow_policy: "drop_worst".into(),
245            driver_event_queue_capacity: crate::event::DEFAULT_EVENT_QUEUE_CAPACITY,
246            inbound_queue_capacities: crate::event::InboundQueueCapacities::default(),
247            interface_writer_queue_capacity: crate::interface::DEFAULT_ASYNC_WRITER_QUEUE_CAPACITY,
248            backbone_peer_pool_max_connected: 0,
249            backbone_peer_pool_failure_threshold: 3,
250            backbone_peer_pool_failure_window: 600,
251            backbone_peer_pool_cooldown: 900,
252            #[cfg(feature = "hooks")]
253            provider_bridge: false,
254            #[cfg(feature = "hooks")]
255            provider_socket_path: None,
256            #[cfg(feature = "hooks")]
257            provider_queue_max_events: 16384,
258            #[cfg(feature = "hooks")]
259            provider_queue_max_bytes: 8 * 1024 * 1024,
260            #[cfg(feature = "hooks")]
261            provider_overflow_policy: "drop_newest".into(),
262        }
263    }
264}
265
266/// The `[logging]` section.
267#[derive(Debug, Clone)]
268pub struct LoggingSection {
269    pub loglevel: u8,
270    pub logtimestamps: bool,
271}
272
273impl Default for LoggingSection {
274    fn default() -> Self {
275        LoggingSection {
276            loglevel: 4,
277            logtimestamps: true,
278        }
279    }
280}
281
282/// A parsed interface from `[[subsection]]` within `[interfaces]`.
283#[derive(Debug, Clone)]
284pub struct ParsedInterface {
285    pub name: String,
286    pub interface_type: String,
287    pub enabled: bool,
288    pub mode: String,
289    pub params: HashMap<String, String>,
290    pub subinterfaces: Vec<ParsedSubinterface>,
291}
292
293/// A `[[[subinterface]]]` nested under an interface section.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct ParsedSubinterface {
296    pub name: String,
297    pub params: HashMap<String, String>,
298}
299
300/// Configuration parse error.
301#[derive(Debug, Clone)]
302pub enum ConfigError {
303    Io(String),
304    Parse(String),
305    InvalidValue { key: String, value: String },
306}
307
308impl fmt::Display for ConfigError {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        match self {
311            ConfigError::Io(msg) => write!(f, "Config I/O error: {}", msg),
312            ConfigError::Parse(msg) => write!(f, "Config parse error: {}", msg),
313            ConfigError::InvalidValue { key, value } => {
314                write!(f, "Invalid value for '{}': '{}'", key, value)
315            }
316        }
317    }
318}
319
320impl From<io::Error> for ConfigError {
321    fn from(e: io::Error) -> Self {
322        ConfigError::Io(e.to_string())
323    }
324}
325
326/// Parse a config string into an `RnsConfig`.
327pub fn parse(input: &str) -> Result<RnsConfig, ConfigError> {
328    let mut current_section: Option<String> = None;
329    let mut current_subsection: Option<String> = None;
330
331    let mut reticulum_kvs: HashMap<String, String> = HashMap::new();
332    let mut logging_kvs: HashMap<String, String> = HashMap::new();
333    let mut interfaces: Vec<ParsedInterface> = Vec::new();
334    let mut current_iface_kvs: Option<HashMap<String, String>> = None;
335    let mut current_iface_name: Option<String> = None;
336    let mut current_iface_subinterfaces: Vec<ParsedSubinterface> = Vec::new();
337    let mut current_subiface_name: Option<String> = None;
338    let mut current_subiface_kvs: Option<HashMap<String, String>> = None;
339    let mut hooks: Vec<ParsedHook> = Vec::new();
340    let mut current_hook_kvs: Option<HashMap<String, String>> = None;
341    let mut current_hook_name: Option<String> = None;
342
343    for line in input.lines() {
344        // Strip comments (# to end of line, unless inside quotes)
345        let line = strip_comment(line);
346        let trimmed = line.trim();
347
348        // Skip empty lines
349        if trimmed.is_empty() {
350            continue;
351        }
352
353        // Check for third-level subsection [[[name]]] before [[name]].
354        if trimmed.starts_with("[[[") && trimmed.ends_with("]]]") {
355            if current_iface_kvs.is_none() {
356                return Err(ConfigError::Parse(
357                    "third-level section outside an interface".into(),
358                ));
359            }
360            if let (Some(name), Some(params)) =
361                (current_subiface_name.take(), current_subiface_kvs.take())
362            {
363                current_iface_subinterfaces.push(ParsedSubinterface { name, params });
364            }
365            current_subiface_name = Some(trimmed[3..trimmed.len() - 3].trim().to_string());
366            current_subiface_kvs = Some(HashMap::new());
367            continue;
368        }
369
370        // Check for subsection [[name]]
371        if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
372            let name = trimmed[2..trimmed.len() - 2].trim().to_string();
373            if let (Some(name), Some(params)) =
374                (current_subiface_name.take(), current_subiface_kvs.take())
375            {
376                current_iface_subinterfaces.push(ParsedSubinterface { name, params });
377            }
378            // Finalize previous interface subsection if any
379            if let (Some(iface_name), Some(kvs)) =
380                (current_iface_name.take(), current_iface_kvs.take())
381            {
382                interfaces.push(build_parsed_interface(
383                    iface_name,
384                    kvs,
385                    std::mem::take(&mut current_iface_subinterfaces),
386                ));
387            }
388            // Finalize previous hook subsection if any
389            if let (Some(hook_name), Some(kvs)) =
390                (current_hook_name.take(), current_hook_kvs.take())
391            {
392                hooks.push(build_parsed_hook(hook_name, kvs));
393            }
394            current_subsection = Some(name.clone());
395            // Determine which section we're in to know subsection type
396            if current_section.as_deref() == Some("hooks") {
397                current_hook_name = Some(name);
398                current_hook_kvs = Some(HashMap::new());
399            } else {
400                current_iface_name = Some(name);
401                current_iface_kvs = Some(HashMap::new());
402            }
403            continue;
404        }
405
406        // Check for section [name]
407        if trimmed.starts_with('[') && trimmed.ends_with(']') {
408            if let (Some(name), Some(params)) =
409                (current_subiface_name.take(), current_subiface_kvs.take())
410            {
411                current_iface_subinterfaces.push(ParsedSubinterface { name, params });
412            }
413            // Finalize previous interface subsection if any
414            if let (Some(iface_name), Some(kvs)) =
415                (current_iface_name.take(), current_iface_kvs.take())
416            {
417                interfaces.push(build_parsed_interface(
418                    iface_name,
419                    kvs,
420                    std::mem::take(&mut current_iface_subinterfaces),
421                ));
422            }
423            // Finalize previous hook subsection if any
424            if let (Some(hook_name), Some(kvs)) =
425                (current_hook_name.take(), current_hook_kvs.take())
426            {
427                hooks.push(build_parsed_hook(hook_name, kvs));
428            }
429            current_subsection = None;
430
431            let name = trimmed[1..trimmed.len() - 1].trim().to_lowercase();
432            current_section = Some(name);
433            continue;
434        }
435
436        // Parse key = value
437        if let Some(eq_pos) = trimmed.find('=') {
438            let key = trimmed[..eq_pos].trim().to_string();
439            let value = trimmed[eq_pos + 1..].trim().to_string();
440
441            if current_subsection.is_some() {
442                // Inside a [[subsection]] — exactly one of these should be Some
443                debug_assert!(
444                    !(current_hook_kvs.is_some() && current_iface_kvs.is_some()),
445                    "hook and interface subsections should never be active simultaneously"
446                );
447                if let Some(ref mut kvs) = current_subiface_kvs {
448                    kvs.insert(key, value);
449                } else if let Some(ref mut kvs) = current_hook_kvs {
450                    kvs.insert(key, value);
451                } else if let Some(ref mut kvs) = current_iface_kvs {
452                    kvs.insert(key, value);
453                }
454            } else if let Some(ref section) = current_section {
455                match section.as_str() {
456                    "reticulum" => {
457                        reticulum_kvs.insert(key, value);
458                    }
459                    "logging" => {
460                        logging_kvs.insert(key, value);
461                    }
462                    _ => {} // ignore unknown sections
463                }
464            }
465        }
466    }
467
468    // Finalize last subsections
469    if let (Some(name), Some(params)) = (current_subiface_name.take(), current_subiface_kvs.take())
470    {
471        current_iface_subinterfaces.push(ParsedSubinterface { name, params });
472    }
473    if let (Some(iface_name), Some(kvs)) = (current_iface_name.take(), current_iface_kvs.take()) {
474        interfaces.push(build_parsed_interface(
475            iface_name,
476            kvs,
477            current_iface_subinterfaces,
478        ));
479    }
480    if let (Some(hook_name), Some(kvs)) = (current_hook_name.take(), current_hook_kvs.take()) {
481        hooks.push(build_parsed_hook(hook_name, kvs));
482    }
483
484    // Build typed sections
485    let reticulum = build_reticulum_section(&reticulum_kvs)?;
486    let logging = build_logging_section(&logging_kvs)?;
487
488    Ok(RnsConfig {
489        reticulum,
490        logging,
491        interfaces,
492        hooks,
493    })
494}
495
496/// Parse a config file from disk.
497pub fn parse_file(path: &Path) -> Result<RnsConfig, ConfigError> {
498    let content = std::fs::read_to_string(path)?;
499    parse(&content)
500}
501
502pub(crate) fn parse_link_mtu_discovery(input: &str) -> Result<bool, ConfigError> {
503    let mut in_reticulum = false;
504    for line in input.lines() {
505        let trimmed = strip_comment(line).trim();
506        if trimmed.starts_with('[') && trimmed.ends_with(']') {
507            in_reticulum = trimmed.eq_ignore_ascii_case("[reticulum]");
508            continue;
509        }
510        if !in_reticulum {
511            continue;
512        }
513        let Some((key, value)) = trimmed.split_once('=') else {
514            continue;
515        };
516        if key.trim() == "link_mtu_discovery" {
517            let value = value.trim();
518            return parse_bool(value).ok_or_else(|| ConfigError::InvalidValue {
519                key: "link_mtu_discovery".into(),
520                value: value.into(),
521            });
522        }
523    }
524    Ok(true)
525}
526
527/// Strip `#` comments from a line (simple: not inside quotes).
528fn strip_comment(line: &str) -> &str {
529    // Find # that is not inside quotes
530    let mut in_quote = false;
531    let mut quote_char = '"';
532    for (i, ch) in line.char_indices() {
533        if !in_quote && (ch == '"' || ch == '\'') {
534            in_quote = true;
535            quote_char = ch;
536        } else if in_quote && ch == quote_char {
537            in_quote = false;
538        } else if !in_quote && ch == '#' {
539            return &line[..i];
540        }
541    }
542    line
543}
544
545/// Parse a string as a boolean (ConfigObj style). Public API for use by node.rs.
546pub fn parse_bool_pub(value: &str) -> Option<bool> {
547    parse_bool(value)
548}
549
550/// Parse a string as a boolean (ConfigObj style).
551fn parse_bool(value: &str) -> Option<bool> {
552    match value.to_lowercase().as_str() {
553        "yes" | "true" | "1" | "on" => Some(true),
554        "no" | "false" | "0" | "off" => Some(false),
555        _ => None,
556    }
557}
558
559fn parse_nonnegative_f64_option(
560    kvs: &HashMap<String, String>,
561    key: &str,
562) -> Result<Option<f64>, ConfigError> {
563    let Some(v) = kvs.get(key) else {
564        return Ok(None);
565    };
566    let parsed = v.parse::<f64>().map_err(|_| ConfigError::InvalidValue {
567        key: key.into(),
568        value: v.clone(),
569    })?;
570    if !parsed.is_finite() || parsed < 0.0 {
571        return Err(ConfigError::InvalidValue {
572            key: key.into(),
573            value: v.clone(),
574        });
575    }
576    Ok(Some(parsed))
577}
578
579fn parse_usize_option(
580    kvs: &HashMap<String, String>,
581    key: &str,
582) -> Result<Option<usize>, ConfigError> {
583    let Some(v) = kvs.get(key) else {
584        return Ok(None);
585    };
586    let parsed = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
587        key: key.into(),
588        value: v.clone(),
589    })?;
590    Ok(Some(parsed))
591}
592
593fn build_parsed_interface(
594    name: String,
595    mut kvs: HashMap<String, String>,
596    subinterfaces: Vec<ParsedSubinterface>,
597) -> ParsedInterface {
598    let interface_type = kvs.remove("type").unwrap_or_default();
599    let enabled = kvs
600        .remove("enabled")
601        .and_then(|v| parse_bool(&v))
602        .unwrap_or(true);
603    // Python checks `interface_mode` first, then falls back to `mode`
604    let mode = kvs
605        .remove("interface_mode")
606        .or_else(|| kvs.remove("mode"))
607        .unwrap_or_else(|| "full".into());
608
609    ParsedInterface {
610        name,
611        interface_type,
612        enabled,
613        mode,
614        params: kvs,
615        subinterfaces,
616    }
617}
618
619fn build_parsed_hook(name: String, mut kvs: HashMap<String, String>) -> ParsedHook {
620    let path = kvs.remove("path").unwrap_or_default();
621    let hook_type = kvs
622        .remove("type")
623        .or_else(|| kvs.remove("backend"))
624        .unwrap_or_else(|| default_hook_type().into());
625    let builtin_id = kvs
626        .remove("builtin")
627        .or_else(|| kvs.remove("builtin_id"))
628        .or_else(|| kvs.remove("id"));
629    let attach_point = kvs.remove("attach_point").unwrap_or_default();
630    let priority = kvs
631        .remove("priority")
632        .and_then(|v| v.parse::<i32>().ok())
633        .unwrap_or(0);
634    let enabled = kvs
635        .remove("enabled")
636        .and_then(|v| parse_bool(&v))
637        .unwrap_or(true);
638
639    ParsedHook {
640        name,
641        path,
642        hook_type,
643        builtin_id,
644        attach_point,
645        priority,
646        enabled,
647    }
648}
649
650fn default_hook_type() -> &'static str {
651    #[cfg(feature = "rns-hooks-native")]
652    {
653        "native"
654    }
655    #[cfg(all(not(feature = "rns-hooks-native"), feature = "rns-hooks-wasm"))]
656    {
657        return "wasm";
658    }
659    #[cfg(all(not(feature = "rns-hooks-native"), not(feature = "rns-hooks-wasm")))]
660    {
661        "wasm"
662    }
663}
664
665/// Map a hook point name string to its index. Returns None for unknown names.
666pub fn parse_hook_point(s: &str) -> Option<usize> {
667    match s {
668        "PreIngress" => Some(0),
669        "PreDispatch" => Some(1),
670        "AnnounceReceived" => Some(2),
671        "PathUpdated" => Some(3),
672        "AnnounceRetransmit" => Some(4),
673        "LinkRequestReceived" => Some(5),
674        "LinkEstablished" => Some(6),
675        "LinkClosed" => Some(7),
676        "InterfaceUp" => Some(8),
677        "InterfaceDown" => Some(9),
678        "InterfaceConfigChanged" => Some(10),
679        "BackbonePeerConnected" => Some(11),
680        "BackbonePeerDisconnected" => Some(12),
681        "BackbonePeerIdleTimeout" => Some(13),
682        "BackbonePeerWriteStall" => Some(14),
683        "BackbonePeerPenalty" => Some(15),
684        "SendOnInterface" => Some(16),
685        "BroadcastOnAllInterfaces" => Some(17),
686        "DeliverLocal" => Some(18),
687        "TunnelSynthesize" => Some(19),
688        "Tick" => Some(20),
689        _ => None,
690    }
691}
692
693#[cfg(feature = "hooks")]
694pub fn parse_hook_backend(s: &str) -> Result<rns_hooks::HookBackend, String> {
695    s.parse()
696}
697
698fn build_reticulum_section(kvs: &HashMap<String, String>) -> Result<ReticulumSection, ConfigError> {
699    let mut section = ReticulumSection::default();
700
701    if let Some(v) = kvs.get("enable_transport") {
702        section.enable_transport = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
703            key: "enable_transport".into(),
704            value: v.clone(),
705        })?;
706    }
707    if let Some(v) = kvs.get("static_transport_identity") {
708        section.static_transport_identity =
709            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
710                key: "static_transport_identity".into(),
711                value: v.clone(),
712            })?;
713    }
714    if let Some(v) = kvs.get("local_hops_delta") {
715        section.local_hops_delta = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
716            key: "local_hops_delta".into(),
717            value: v.clone(),
718        })?;
719    }
720    if let Some(v) = kvs.get("share_instance") {
721        section.share_instance = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
722            key: "share_instance".into(),
723            value: v.clone(),
724        })?;
725    }
726    if let Some(v) = kvs.get("enable_interface_management") {
727        section.enable_interface_management =
728            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
729                key: "enable_interface_management".into(),
730                value: v.clone(),
731            })?;
732    }
733    if let Some(v) = kvs.get("instance_name") {
734        section.instance_name = v.clone();
735    }
736    if let Some(v) = kvs.get("shared_instance_port") {
737        section.shared_instance_port = v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
738            key: "shared_instance_port".into(),
739            value: v.clone(),
740        })?;
741    }
742    if let Some(v) = kvs.get("instance_control_port") {
743        section.instance_control_port =
744            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
745                key: "instance_control_port".into(),
746                value: v.clone(),
747            })?;
748    }
749    if let Some(v) = kvs.get("panic_on_interface_error") {
750        section.panic_on_interface_error =
751            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
752                key: "panic_on_interface_error".into(),
753                value: v.clone(),
754            })?;
755    }
756    if let Some(v) = kvs.get("use_implicit_proof") {
757        section.use_implicit_proof = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
758            key: "use_implicit_proof".into(),
759            value: v.clone(),
760        })?;
761    }
762    if let Some(v) = kvs.get("network_identity") {
763        section.network_identity = Some(v.clone());
764    }
765    if let Some(v) = kvs.get("respond_to_probes") {
766        section.respond_to_probes = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
767            key: "respond_to_probes".into(),
768            value: v.clone(),
769        })?;
770    }
771    if let Some(v) = kvs.get("enable_remote_management") {
772        section.enable_remote_management =
773            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
774                key: "enable_remote_management".into(),
775                value: v.clone(),
776            })?;
777    }
778    if let Some(v) = kvs.get("remote_management_allowed") {
779        // Value is a comma-separated list of hex identity hashes
780        for item in v.split(',') {
781            let trimmed = item.trim();
782            if !trimmed.is_empty() {
783                section.remote_management_allowed.push(trimmed.to_string());
784            }
785        }
786    }
787    if let Some(v) = kvs.get("publish_blackhole") {
788        section.publish_blackhole = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
789            key: "publish_blackhole".into(),
790            value: v.clone(),
791        })?;
792    }
793    if let Some(v) = kvs.get("probe_port") {
794        section.probe_port = Some(v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
795            key: "probe_port".into(),
796            value: v.clone(),
797        })?);
798    }
799    if let Some(v) = kvs.get("probe_addr") {
800        section.probe_addr = Some(v.clone());
801    }
802    if let Some(v) = kvs.get("probe_protocol") {
803        section.probe_protocol = Some(v.clone());
804    }
805    if let Some(v) = kvs.get("direct_connect_policy") {
806        section.direct_connect_policy = match v.to_ascii_lowercase().as_str() {
807            "reject" => crate::event::HolePunchPolicy::Reject,
808            "accept_all" => crate::event::HolePunchPolicy::AcceptAll,
809            "identified_only" => crate::event::HolePunchPolicy::IdentifiedOnly,
810            "ask_app" => crate::event::HolePunchPolicy::AskApp,
811            _ => {
812                return Err(ConfigError::InvalidValue {
813                    key: "direct_connect_policy".into(),
814                    value: v.clone(),
815                })
816            }
817        };
818    }
819    if let Some(v) = kvs.get("direct_connect_allow_private_endpoints") {
820        section.direct_connect_allow_private_endpoints =
821            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
822                key: "direct_connect_allow_private_endpoints".into(),
823                value: v.clone(),
824            })?;
825    }
826    if let Some(v) = kvs.get("device") {
827        section.device = Some(v.clone());
828    }
829    if let Some(v) = kvs.get("underlay_mark") {
830        let mark = v.parse::<u32>().map_err(|_| ConfigError::InvalidValue {
831            key: "underlay_mark".into(),
832            value: v.clone(),
833        })?;
834        if mark == 0 {
835            return Err(ConfigError::InvalidValue {
836                key: "underlay_mark".into(),
837                value: v.clone(),
838            });
839        }
840        section.underlay_mark = Some(mark);
841    }
842    if let Some(v) = kvs.get("discover_interfaces") {
843        section.discover_interfaces = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
844            key: "discover_interfaces".into(),
845            value: v.clone(),
846        })?;
847    }
848    if let Some(v) = kvs.get("default_gravity") {
849        section.default_gravity = v.parse::<i64>().map_err(|_| ConfigError::InvalidValue {
850            key: "default_gravity".into(),
851            value: v.clone(),
852        })?;
853    }
854    // Retain the short-lived pre-release name as a compatibility alias. The
855    // final upstream option takes precedence when both are present.
856    if let Some(v) = kvs.get("autoconnect_discovered_mode") {
857        section.autoconnect_interface_mode = Some(v.to_lowercase());
858    }
859    if let Some(v) = kvs.get("autoconnect_interface_mode") {
860        section.autoconnect_interface_mode = Some(v.to_lowercase());
861    }
862    if let Some(v) = kvs.get("autoconnect_interface_gravity") {
863        section.autoconnect_interface_gravity =
864            v.parse::<i64>().map_err(|_| ConfigError::InvalidValue {
865                key: "autoconnect_interface_gravity".into(),
866                value: v.clone(),
867            })?;
868    }
869    if let Some(v) = kvs.get("autoconnect_announces_to_internal") {
870        section.autoconnect_announces_to_internal =
871            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
872                key: "autoconnect_announces_to_internal".into(),
873                value: v.clone(),
874            })?;
875    }
876    if let Some(v) = kvs.get("autoconnect_unverified_implementations") {
877        section.autoconnect_unverified_implementations =
878            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
879                key: "autoconnect_unverified_implementations".into(),
880                value: v.clone(),
881            })?;
882    }
883    if let Some(v) = kvs.get("required_discovery_value") {
884        section.required_discovery_value =
885            Some(v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
886                key: "required_discovery_value".into(),
887                value: v.clone(),
888            })?);
889    }
890    if let Some(v) = kvs.get("prefer_shorter_path") {
891        section.prefer_shorter_path = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
892            key: "prefer_shorter_path".into(),
893            value: v.clone(),
894        })?;
895    }
896    if let Some(v) = kvs.get("max_paths_per_destination") {
897        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
898            key: "max_paths_per_destination".into(),
899            value: v.clone(),
900        })?;
901        section.max_paths_per_destination = n.max(1);
902    }
903    if let Some(v) = kvs.get("packet_hashlist_max_entries") {
904        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
905            key: "packet_hashlist_max_entries".into(),
906            value: v.clone(),
907        })?;
908        section.packet_hashlist_max_entries = n.max(1);
909    }
910    if let Some(v) = kvs.get("packet_hashlist_allocation") {
911        section.packet_hashlist_allocation = match v.to_ascii_lowercase().as_str() {
912            "eager" => rns_core::transport::types::PacketHashlistAllocation::Eager,
913            "lazy" => rns_core::transport::types::PacketHashlistAllocation::Lazy,
914            _ => {
915                return Err(ConfigError::InvalidValue {
916                    key: "packet_hashlist_allocation".into(),
917                    value: v.clone(),
918                });
919            }
920        };
921    }
922    if let Some(v) = kvs.get("max_discovery_pr_tags") {
923        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
924            key: "max_discovery_pr_tags".into(),
925            value: v.clone(),
926        })?;
927        section.max_discovery_pr_tags = n.max(1);
928    }
929    if let Some(v) = kvs.get("max_path_destinations") {
930        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
931            key: "max_path_destinations".into(),
932            value: v.clone(),
933        })?;
934        section.max_path_destinations = n.max(1);
935    }
936    if let Some(v) = kvs.get("max_tunnel_destinations_total") {
937        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
938            key: "max_tunnel_destinations_total".into(),
939            value: v.clone(),
940        })?;
941        section.max_tunnel_destinations_total = n.max(1);
942    }
943    if let Some(v) = kvs.get("known_destinations_ttl") {
944        section.known_destinations_ttl =
945            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
946                key: "known_destinations_ttl".into(),
947                value: v.clone(),
948            })?;
949    }
950    if let Some(v) = kvs.get("known_destinations_max_entries") {
951        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
952            key: "known_destinations_max_entries".into(),
953            value: v.clone(),
954        })?;
955        if n == 0 {
956            return Err(ConfigError::InvalidValue {
957                key: "known_destinations_max_entries".into(),
958                value: v.clone(),
959            });
960        }
961        section.known_destinations_max_entries = n;
962    }
963    if let Some(v) = kvs.get("ratchet_expiry") {
964        let expiry = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
965            key: "ratchet_expiry".into(),
966            value: v.clone(),
967        })?;
968        if expiry == 0 {
969            return Err(ConfigError::InvalidValue {
970                key: "ratchet_expiry".into(),
971                value: v.clone(),
972            });
973        }
974        section.ratchet_expiry = expiry;
975    }
976    if let Some(v) = kvs.get("destination_timeout_secs") {
977        section.known_destinations_ttl =
978            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
979                key: "destination_timeout_secs".into(),
980                value: v.clone(),
981            })?;
982    }
983    if let Some(v) = kvs.get("announce_table_ttl") {
984        let ttl = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
985            key: "announce_table_ttl".into(),
986            value: v.clone(),
987        })?;
988        if ttl == 0 {
989            return Err(ConfigError::InvalidValue {
990                key: "announce_table_ttl".into(),
991                value: v.clone(),
992            });
993        }
994        section.announce_table_ttl = ttl;
995    }
996    if let Some(v) = kvs.get("announce_table_max_bytes") {
997        let max_bytes = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
998            key: "announce_table_max_bytes".into(),
999            value: v.clone(),
1000        })?;
1001        if max_bytes == 0 {
1002            return Err(ConfigError::InvalidValue {
1003                key: "announce_table_max_bytes".into(),
1004                value: v.clone(),
1005            });
1006        }
1007        section.announce_table_max_bytes = max_bytes;
1008    }
1009    if let Some(v) = kvs.get("announce_signature_cache_enabled") {
1010        section.announce_sig_cache_enabled = match v.as_str() {
1011            "true" | "yes" | "True" | "Yes" => true,
1012            "false" | "no" | "False" | "No" => false,
1013            _ => {
1014                return Err(ConfigError::InvalidValue {
1015                    key: "announce_signature_cache_enabled".into(),
1016                    value: v.clone(),
1017                })
1018            }
1019        };
1020    }
1021    if let Some(v) = kvs.get("announce_signature_cache_max_entries") {
1022        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1023            key: "announce_signature_cache_max_entries".into(),
1024            value: v.clone(),
1025        })?;
1026        section.announce_sig_cache_max_entries = n;
1027    }
1028    if let Some(v) = kvs.get("announce_signature_cache_ttl") {
1029        let ttl = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
1030            key: "announce_signature_cache_ttl".into(),
1031            value: v.clone(),
1032        })?;
1033        section.announce_sig_cache_ttl = ttl;
1034    }
1035    if let Some(v) = kvs.get("announce_queue_max_entries") {
1036        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1037            key: "announce_queue_max_entries".into(),
1038            value: v.clone(),
1039        })?;
1040        if n == 0 {
1041            return Err(ConfigError::InvalidValue {
1042                key: "announce_queue_max_entries".into(),
1043                value: v.clone(),
1044            });
1045        }
1046        section.announce_queue_max_entries = n;
1047    }
1048    if let Some(v) = kvs.get("announce_queue_max_interfaces") {
1049        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1050            key: "announce_queue_max_interfaces".into(),
1051            value: v.clone(),
1052        })?;
1053        if n == 0 {
1054            return Err(ConfigError::InvalidValue {
1055                key: "announce_queue_max_interfaces".into(),
1056                value: v.clone(),
1057            });
1058        }
1059        section.announce_queue_max_interfaces = n;
1060    }
1061    if let Some(v) = kvs.get("default_ar_target") {
1062        let target = v.parse::<f64>().map_err(|_| ConfigError::InvalidValue {
1063            key: "default_ar_target".into(),
1064            value: v.clone(),
1065        })?;
1066        if !target.is_finite() || target < 0.0 {
1067            return Err(ConfigError::InvalidValue {
1068                key: "default_ar_target".into(),
1069                value: v.clone(),
1070            });
1071        }
1072        section.default_ar_target = if target == 0.0 { None } else { Some(target) };
1073    }
1074    if let Some(v) = kvs.get("default_ar_penalty") {
1075        let penalty = v.parse::<f64>().map_err(|_| ConfigError::InvalidValue {
1076            key: "default_ar_penalty".into(),
1077            value: v.clone(),
1078        })?;
1079        if !penalty.is_finite() || penalty < 0.0 {
1080            return Err(ConfigError::InvalidValue {
1081                key: "default_ar_penalty".into(),
1082                value: v.clone(),
1083            });
1084        }
1085        section.default_ar_penalty = penalty;
1086    }
1087    if let Some(v) = kvs.get("default_ar_grace") {
1088        let grace = v.parse::<u32>().map_err(|_| ConfigError::InvalidValue {
1089            key: "default_ar_grace".into(),
1090            value: v.clone(),
1091        })?;
1092        section.default_ar_grace = grace;
1093    }
1094    if let Some(v) = parse_usize_option(kvs, "ic_max_held_announces")? {
1095        section.default_ic_max_held_announces = v;
1096    }
1097    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_hold")? {
1098        section.default_ic_burst_hold = v;
1099    }
1100    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_freq_new")? {
1101        section.default_ic_burst_freq_new = v;
1102    }
1103    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_freq")? {
1104        section.default_ic_burst_freq = v;
1105    }
1106    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_pr_burst_freq_new")? {
1107        section.default_ic_pr_burst_freq_new = v;
1108    }
1109    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_pr_burst_freq")? {
1110        section.default_ic_pr_burst_freq = v;
1111    }
1112    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_new_time")? {
1113        section.default_ic_new_time = v;
1114    }
1115    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_burst_penalty")? {
1116        section.default_ic_burst_penalty = v;
1117    }
1118    if let Some(v) = parse_nonnegative_f64_option(kvs, "ic_held_release_interval")? {
1119        section.default_ic_held_release_interval = v;
1120    }
1121    if let Some(v) = kvs.get("egress_control") {
1122        section.default_egress_control =
1123            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
1124                key: "egress_control".into(),
1125                value: v.clone(),
1126            })?;
1127    }
1128    if let Some(v) = parse_nonnegative_f64_option(kvs, "ec_pr_freq")? {
1129        section.default_ec_pr_freq = v;
1130    }
1131    if let Some(v) = kvs.get("announce_queue_max_bytes") {
1132        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1133            key: "announce_queue_max_bytes".into(),
1134            value: v.clone(),
1135        })?;
1136        if n == 0 {
1137            return Err(ConfigError::InvalidValue {
1138                key: "announce_queue_max_bytes".into(),
1139                value: v.clone(),
1140            });
1141        }
1142        section.announce_queue_max_bytes = n;
1143    }
1144    if let Some(v) = kvs.get("announce_queue_ttl") {
1145        let ttl = v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
1146            key: "announce_queue_ttl".into(),
1147            value: v.clone(),
1148        })?;
1149        if ttl == 0 {
1150            return Err(ConfigError::InvalidValue {
1151                key: "announce_queue_ttl".into(),
1152                value: v.clone(),
1153            });
1154        }
1155        section.announce_queue_ttl = ttl;
1156    }
1157    if let Some(v) = kvs.get("announce_queue_overflow_policy") {
1158        let normalized = v.to_lowercase();
1159        if normalized != "drop_newest" && normalized != "drop_oldest" && normalized != "drop_worst"
1160        {
1161            return Err(ConfigError::InvalidValue {
1162                key: "announce_queue_overflow_policy".into(),
1163                value: v.clone(),
1164            });
1165        }
1166        section.announce_queue_overflow_policy = normalized;
1167    }
1168    if let Some(v) = kvs.get("driver_event_queue_capacity") {
1169        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1170            key: "driver_event_queue_capacity".into(),
1171            value: v.clone(),
1172        })?;
1173        if n == 0 {
1174            return Err(ConfigError::InvalidValue {
1175                key: "driver_event_queue_capacity".into(),
1176                value: v.clone(),
1177            });
1178        }
1179        section.driver_event_queue_capacity = n;
1180        section.inbound_queue_capacities =
1181            crate::event::InboundQueueCapacities::from_shared_capacity(n);
1182    }
1183    for (key, queue) in [
1184        ("qlen_in_data", 0),
1185        ("qlen_in_announce", 1),
1186        ("qlen_in_pr", 2),
1187        ("qlen_in_il", 3),
1188    ] {
1189        let Some(v) = kvs.get(key) else {
1190            continue;
1191        };
1192        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1193            key: key.into(),
1194            value: v.clone(),
1195        })?;
1196        if n == 0 {
1197            return Err(ConfigError::InvalidValue {
1198                key: key.into(),
1199                value: v.clone(),
1200            });
1201        }
1202        match queue {
1203            0 => section.inbound_queue_capacities.data = n,
1204            1 => section.inbound_queue_capacities.announce = n,
1205            2 => section.inbound_queue_capacities.path_request = n,
1206            _ => section.inbound_queue_capacities.ingress_limited = n,
1207        }
1208    }
1209    if let Some(v) = kvs.get("interface_writer_queue_capacity") {
1210        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1211            key: "interface_writer_queue_capacity".into(),
1212            value: v.clone(),
1213        })?;
1214        if n == 0 {
1215            return Err(ConfigError::InvalidValue {
1216                key: "interface_writer_queue_capacity".into(),
1217                value: v.clone(),
1218            });
1219        }
1220        section.interface_writer_queue_capacity = n;
1221    }
1222    if let Some(v) = kvs.get("backbone_peer_pool_max_connected") {
1223        section.backbone_peer_pool_max_connected =
1224            v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1225                key: "backbone_peer_pool_max_connected".into(),
1226                value: v.clone(),
1227            })?;
1228    }
1229    if let Some(v) = kvs.get("backbone_peer_pool_failure_threshold") {
1230        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1231            key: "backbone_peer_pool_failure_threshold".into(),
1232            value: v.clone(),
1233        })?;
1234        if n == 0 {
1235            return Err(ConfigError::InvalidValue {
1236                key: "backbone_peer_pool_failure_threshold".into(),
1237                value: v.clone(),
1238            });
1239        }
1240        section.backbone_peer_pool_failure_threshold = n;
1241    }
1242    if let Some(v) = kvs.get("backbone_peer_pool_failure_window") {
1243        section.backbone_peer_pool_failure_window =
1244            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
1245                key: "backbone_peer_pool_failure_window".into(),
1246                value: v.clone(),
1247            })?;
1248    }
1249    if let Some(v) = kvs.get("backbone_peer_pool_cooldown") {
1250        section.backbone_peer_pool_cooldown =
1251            v.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
1252                key: "backbone_peer_pool_cooldown".into(),
1253                value: v.clone(),
1254            })?;
1255    }
1256    #[cfg(feature = "hooks")]
1257    if let Some(v) = kvs.get("provider_bridge") {
1258        section.provider_bridge = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
1259            key: "provider_bridge".into(),
1260            value: v.clone(),
1261        })?;
1262    }
1263    #[cfg(feature = "hooks")]
1264    if let Some(v) = kvs.get("provider_socket_path") {
1265        section.provider_socket_path = Some(v.clone());
1266    }
1267    #[cfg(feature = "hooks")]
1268    if let Some(v) = kvs.get("provider_queue_max_events") {
1269        section.provider_queue_max_events =
1270            v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1271                key: "provider_queue_max_events".into(),
1272                value: v.clone(),
1273            })?;
1274    }
1275    #[cfg(feature = "hooks")]
1276    if let Some(v) = kvs.get("provider_queue_max_bytes") {
1277        section.provider_queue_max_bytes =
1278            v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
1279                key: "provider_queue_max_bytes".into(),
1280                value: v.clone(),
1281            })?;
1282    }
1283    #[cfg(feature = "hooks")]
1284    if let Some(v) = kvs.get("provider_overflow_policy") {
1285        let normalized = v.to_lowercase();
1286        if normalized != "drop_newest" && normalized != "drop_oldest" {
1287            return Err(ConfigError::InvalidValue {
1288                key: "provider_overflow_policy".into(),
1289                value: v.clone(),
1290            });
1291        }
1292        section.provider_overflow_policy = normalized;
1293    }
1294
1295    Ok(section)
1296}
1297
1298fn build_logging_section(kvs: &HashMap<String, String>) -> Result<LoggingSection, ConfigError> {
1299    let mut section = LoggingSection::default();
1300
1301    if let Some(v) = kvs.get("loglevel") {
1302        section.loglevel = v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
1303            key: "loglevel".into(),
1304            value: v.clone(),
1305        })?;
1306        if section.loglevel > crate::logging::LOG_EXTREME {
1307            return Err(ConfigError::InvalidValue {
1308                key: "loglevel".into(),
1309                value: v.clone(),
1310            });
1311        }
1312    }
1313    if let Some(v) = kvs.get("logtimestamps") {
1314        section.logtimestamps = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
1315            key: "logtimestamps".into(),
1316            value: v.clone(),
1317        })?;
1318    }
1319
1320    Ok(section)
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325    use super::*;
1326
1327    #[test]
1328    fn parse_empty() {
1329        let config = parse("").unwrap();
1330        assert!(!config.reticulum.enable_transport);
1331        assert!(!config.reticulum.static_transport_identity);
1332        assert!(!config.reticulum.local_hops_delta);
1333        assert!(config.reticulum.share_instance);
1334        assert_eq!(config.reticulum.instance_name, "default");
1335        assert_eq!(config.logging.loglevel, 4);
1336        assert!(config.logging.logtimestamps);
1337        assert!(config.interfaces.is_empty());
1338        assert_eq!(
1339            config.reticulum.packet_hashlist_max_entries,
1340            rns_core::constants::HASHLIST_MAXSIZE
1341        );
1342        assert_eq!(
1343            config.reticulum.packet_hashlist_allocation,
1344            rns_core::transport::types::PacketHashlistAllocation::Eager
1345        );
1346        assert_eq!(
1347            config.reticulum.announce_table_ttl,
1348            rns_core::constants::ANNOUNCE_TABLE_TTL as u64
1349        );
1350        assert_eq!(
1351            config.reticulum.announce_table_max_bytes,
1352            rns_core::constants::ANNOUNCE_TABLE_MAX_BYTES
1353        );
1354        assert_eq!(
1355            config.reticulum.ratchet_expiry,
1356            rns_core::constants::RATCHET_EXPIRY
1357        );
1358        assert_eq!(
1359            config.reticulum.inbound_queue_capacities,
1360            crate::event::InboundQueueCapacities::default()
1361        );
1362    }
1363
1364    #[test]
1365    fn parse_reticulum_inbound_queue_lengths() {
1366        let config = parse(
1367            r#"
1368[reticulum]
1369qlen_in_data = 101
1370qlen_in_announce = 102
1371qlen_in_pr = 103
1372qlen_in_il = 104
1373"#,
1374        )
1375        .unwrap();
1376
1377        assert_eq!(
1378            config.reticulum.inbound_queue_capacities,
1379            crate::event::InboundQueueCapacities {
1380                data: 101,
1381                announce: 102,
1382                path_request: 103,
1383                ingress_limited: 104,
1384            }
1385        );
1386    }
1387
1388    #[test]
1389    fn inbound_queue_lengths_must_be_positive_integers() {
1390        for key in [
1391            "qlen_in_data",
1392            "qlen_in_announce",
1393            "qlen_in_pr",
1394            "qlen_in_il",
1395        ] {
1396            for value in ["0", "-1", "many"] {
1397                let input = format!("[reticulum]\n{key} = {value}\n");
1398                assert!(matches!(
1399                    parse(&input),
1400                    Err(ConfigError::InvalidValue { key: invalid_key, .. })
1401                        if invalid_key == key
1402                ));
1403            }
1404        }
1405    }
1406
1407    #[test]
1408    fn legacy_event_capacity_still_scales_class_defaults() {
1409        let config = parse("[reticulum]\ndriver_event_queue_capacity = 64\n").unwrap();
1410        assert_eq!(
1411            config.reticulum.inbound_queue_capacities,
1412            crate::event::InboundQueueCapacities {
1413                data: 64,
1414                announce: 64,
1415                path_request: 64,
1416                ingress_limited: 8,
1417            }
1418        );
1419    }
1420
1421    #[cfg(feature = "hooks")]
1422    #[test]
1423    fn parse_provider_bridge_config() {
1424        let config = parse(
1425            r#"
1426[reticulum]
1427provider_bridge = yes
1428provider_socket_path = /tmp/rns-provider.sock
1429provider_queue_max_events = 42
1430provider_queue_max_bytes = 8192
1431provider_overflow_policy = drop_oldest
1432"#,
1433        )
1434        .unwrap();
1435
1436        assert!(config.reticulum.provider_bridge);
1437        assert_eq!(
1438            config.reticulum.provider_socket_path.as_deref(),
1439            Some("/tmp/rns-provider.sock")
1440        );
1441        assert_eq!(config.reticulum.provider_queue_max_events, 42);
1442        assert_eq!(config.reticulum.provider_queue_max_bytes, 8192);
1443        assert_eq!(config.reticulum.provider_overflow_policy, "drop_oldest");
1444    }
1445
1446    #[test]
1447    fn parse_default_config() {
1448        // The default config from Python's __default_rns_config__
1449        let input = r#"
1450[reticulum]
1451enable_transport = False
1452share_instance = Yes
1453instance_name = default
1454
1455[logging]
1456loglevel = 4
1457
1458[interfaces]
1459
1460  [[Default Interface]]
1461    type = AutoInterface
1462    enabled = Yes
1463"#;
1464        let config = parse(input).unwrap();
1465        assert!(!config.reticulum.enable_transport);
1466        assert!(config.reticulum.share_instance);
1467        assert_eq!(config.reticulum.instance_name, "default");
1468        assert_eq!(config.logging.loglevel, 4);
1469        assert_eq!(config.interfaces.len(), 1);
1470        assert_eq!(config.interfaces[0].name, "Default Interface");
1471        assert_eq!(config.interfaces[0].interface_type, "AutoInterface");
1472        assert!(config.interfaces[0].enabled);
1473    }
1474
1475    #[test]
1476    fn parse_reticulum_section() {
1477        let input = r#"
1478[reticulum]
1479enable_transport = True
1480share_instance = No
1481instance_name = mynode
1482shared_instance_port = 12345
1483instance_control_port = 12346
1484panic_on_interface_error = Yes
1485use_implicit_proof = False
1486respond_to_probes = True
1487network_identity = /home/user/.reticulum/identity
1488known_destinations_ttl = 1234
1489known_destinations_max_entries = 4321
1490ratchet_expiry = 9876
1491announce_table_ttl = 45
1492announce_table_max_bytes = 65536
1493packet_hashlist_max_entries = 321
1494max_discovery_pr_tags = 222
1495max_path_destinations = 111
1496max_tunnel_destinations_total = 99
1497announce_signature_cache_enabled = false
1498announce_signature_cache_max_entries = 500
1499announce_signature_cache_ttl = 300
1500announce_queue_max_entries = 123
1501announce_queue_max_interfaces = 321
1502announce_queue_max_bytes = 4567
1503announce_queue_ttl = 89
1504announce_queue_overflow_policy = drop_oldest
1505driver_event_queue_capacity = 6543
1506interface_writer_queue_capacity = 210
1507underlay_mark = 51820
1508backbone_peer_pool_max_connected = 6
1509backbone_peer_pool_failure_threshold = 4
1510backbone_peer_pool_failure_window = 120
1511backbone_peer_pool_cooldown = 300
1512"#;
1513        let config = parse(input).unwrap();
1514        assert!(config.reticulum.enable_transport);
1515        assert!(!config.reticulum.share_instance);
1516        assert_eq!(config.reticulum.instance_name, "mynode");
1517        assert_eq!(config.reticulum.shared_instance_port, 12345);
1518        assert_eq!(config.reticulum.instance_control_port, 12346);
1519        assert!(config.reticulum.panic_on_interface_error);
1520        assert!(!config.reticulum.use_implicit_proof);
1521        assert!(config.reticulum.respond_to_probes);
1522        assert_eq!(config.reticulum.underlay_mark, Some(51820));
1523        assert_eq!(
1524            config.reticulum.network_identity.as_deref(),
1525            Some("/home/user/.reticulum/identity")
1526        );
1527        assert_eq!(config.reticulum.known_destinations_ttl, 1234);
1528        assert_eq!(config.reticulum.known_destinations_max_entries, 4321);
1529        assert_eq!(config.reticulum.ratchet_expiry, 9876);
1530        assert_eq!(config.reticulum.announce_table_ttl, 45);
1531        assert_eq!(config.reticulum.announce_table_max_bytes, 65536);
1532        assert_eq!(config.reticulum.packet_hashlist_max_entries, 321);
1533        assert_eq!(config.reticulum.max_discovery_pr_tags, 222);
1534        assert_eq!(config.reticulum.max_path_destinations, 111);
1535        assert_eq!(config.reticulum.max_tunnel_destinations_total, 99);
1536        assert!(!config.reticulum.announce_sig_cache_enabled);
1537        assert_eq!(config.reticulum.announce_sig_cache_max_entries, 500);
1538        assert_eq!(config.reticulum.announce_sig_cache_ttl, 300);
1539        assert_eq!(config.reticulum.announce_queue_max_entries, 123);
1540        assert_eq!(config.reticulum.announce_queue_max_interfaces, 321);
1541        assert_eq!(config.reticulum.announce_queue_max_bytes, 4567);
1542        assert_eq!(config.reticulum.announce_queue_ttl, 89);
1543        assert_eq!(
1544            config.reticulum.announce_queue_overflow_policy,
1545            "drop_oldest"
1546        );
1547        assert_eq!(config.reticulum.driver_event_queue_capacity, 6543);
1548        assert_eq!(config.reticulum.interface_writer_queue_capacity, 210);
1549        assert_eq!(config.reticulum.backbone_peer_pool_max_connected, 6);
1550        assert_eq!(config.reticulum.backbone_peer_pool_failure_threshold, 4);
1551        assert_eq!(config.reticulum.backbone_peer_pool_failure_window, 120);
1552        assert_eq!(config.reticulum.backbone_peer_pool_cooldown, 300);
1553    }
1554
1555    #[test]
1556    fn underlay_mark_defaults_to_disabled() {
1557        let config = parse("[reticulum]\n").unwrap();
1558        assert_eq!(config.reticulum.underlay_mark, None);
1559    }
1560
1561    #[test]
1562    fn underlay_mark_rejects_zero_and_non_numeric_values() {
1563        for value in ["0", "-1", "not-a-mark", "4294967296"] {
1564            let error = parse(&format!("[reticulum]\nunderlay_mark = {value}\n"))
1565                .expect_err("invalid underlay mark must be rejected");
1566            assert!(matches!(
1567                error,
1568                ConfigError::InvalidValue { ref key, .. } if key == "underlay_mark"
1569            ));
1570        }
1571    }
1572
1573    #[test]
1574    fn parse_reticulum_announce_rate_defaults() {
1575        let input = r#"
1576[reticulum]
1577default_ar_target = 7200
1578default_ar_penalty = 15
1579default_ar_grace = 7
1580"#;
1581        let config = parse(input).unwrap();
1582
1583        assert_eq!(config.reticulum.default_ar_target, Some(7200.0));
1584        assert_eq!(config.reticulum.default_ar_penalty, 15.0);
1585        assert_eq!(config.reticulum.default_ar_grace, 7);
1586    }
1587
1588    #[test]
1589    fn parse_interface_gravity_defaults_and_autoconnect_options() {
1590        let config = parse(
1591            r#"
1592[reticulum]
1593default_gravity = -3
1594autoconnect_discovered_mode = full
1595autoconnect_interface_mode = boundary
1596autoconnect_interface_gravity = 7
1597autoconnect_announces_to_internal = yes
1598autoconnect_unverified_implementations = yes
1599"#,
1600        )
1601        .unwrap();
1602
1603        assert_eq!(
1604            config.reticulum.autoconnect_interface_mode.as_deref(),
1605            Some("boundary")
1606        );
1607        assert_eq!(config.reticulum.default_gravity, -3);
1608        assert_eq!(config.reticulum.autoconnect_interface_gravity, 7);
1609        assert!(config.reticulum.autoconnect_announces_to_internal);
1610        assert!(config.reticulum.autoconnect_unverified_implementations);
1611    }
1612
1613    #[test]
1614    fn autoconnect_unverified_implementations_defaults_to_false() {
1615        let config = parse("[reticulum]\n").unwrap();
1616        assert!(!config.reticulum.autoconnect_unverified_implementations);
1617    }
1618
1619    #[test]
1620    fn interface_gravity_options_default_to_zero() {
1621        let config = parse("[reticulum]\n").unwrap();
1622        assert_eq!(config.reticulum.default_gravity, 0);
1623        assert_eq!(config.reticulum.autoconnect_interface_gravity, 0);
1624    }
1625
1626    #[test]
1627    fn parses_packet_hashlist_allocation_case_insensitively() {
1628        for (value, expected) in [
1629            (
1630                "eager",
1631                rns_core::transport::types::PacketHashlistAllocation::Eager,
1632            ),
1633            (
1634                "LAZY",
1635                rns_core::transport::types::PacketHashlistAllocation::Lazy,
1636            ),
1637            (
1638                "LaZy",
1639                rns_core::transport::types::PacketHashlistAllocation::Lazy,
1640            ),
1641        ] {
1642            let input = format!("[reticulum]\npacket_hashlist_allocation = {value}\n");
1643            assert_eq!(
1644                parse(&input).unwrap().reticulum.packet_hashlist_allocation,
1645                expected
1646            );
1647        }
1648    }
1649
1650    #[test]
1651    fn rejects_invalid_packet_hashlist_allocation() {
1652        assert!(parse("[reticulum]\npacket_hashlist_allocation = dynamic\n").is_err());
1653    }
1654
1655    #[test]
1656    fn rejects_non_integer_global_gravity_options() {
1657        assert!(parse("[reticulum]\ndefault_gravity = heavy\n").is_err());
1658        assert!(parse("[reticulum]\nautoconnect_interface_gravity = light\n").is_err());
1659    }
1660
1661    #[test]
1662    fn parse_reticulum_announce_rate_target_zero_disables_default() {
1663        let input = r#"
1664[reticulum]
1665default_ar_target = 0
1666default_ar_penalty = 0
1667default_ar_grace = 0
1668"#;
1669        let config = parse(input).unwrap();
1670
1671        assert_eq!(config.reticulum.default_ar_target, None);
1672        assert_eq!(config.reticulum.default_ar_penalty, 0.0);
1673        assert_eq!(config.reticulum.default_ar_grace, 0);
1674    }
1675
1676    #[test]
1677    fn parse_reticulum_announce_rate_defaults_reject_negative_values() {
1678        for (key, value) in [
1679            ("default_ar_target", "-1"),
1680            ("default_ar_target", "NaN"),
1681            ("default_ar_target", "inf"),
1682            ("default_ar_penalty", "-1"),
1683            ("default_ar_penalty", "NaN"),
1684            ("default_ar_penalty", "inf"),
1685            ("default_ar_grace", "-1"),
1686        ] {
1687            let input = format!("[reticulum]\n{key} = {value}\n");
1688            let err = parse(&input).unwrap_err();
1689            assert!(
1690                err.to_string().contains(key),
1691                "error {err:?} should mention {key}"
1692            );
1693        }
1694    }
1695
1696    #[test]
1697    fn parse_reticulum_ingress_and_egress_control_defaults() {
1698        let input = r#"[reticulum]
1699ic_max_held_announces = 17
1700ic_burst_hold = 1.5
1701ic_burst_freq_new = 2.5
1702ic_burst_freq = 3.5
1703ic_pr_burst_freq_new = 4.5
1704ic_pr_burst_freq = 5.5
1705ic_new_time = 6.5
1706ic_burst_penalty = 7.5
1707ic_held_release_interval = 8.5
1708egress_control = Yes
1709ec_pr_freq = 9.5
1710"#;
1711
1712        let config = parse(input).unwrap();
1713
1714        assert_eq!(config.reticulum.default_ic_max_held_announces, 17);
1715        assert_eq!(config.reticulum.default_ic_burst_hold, 1.5);
1716        assert_eq!(config.reticulum.default_ic_burst_freq_new, 2.5);
1717        assert_eq!(config.reticulum.default_ic_burst_freq, 3.5);
1718        assert_eq!(config.reticulum.default_ic_pr_burst_freq_new, 4.5);
1719        assert_eq!(config.reticulum.default_ic_pr_burst_freq, 5.5);
1720        assert_eq!(config.reticulum.default_ic_new_time, 6.5);
1721        assert_eq!(config.reticulum.default_ic_burst_penalty, 7.5);
1722        assert_eq!(config.reticulum.default_ic_held_release_interval, 8.5);
1723        assert!(config.reticulum.default_egress_control);
1724        assert_eq!(config.reticulum.default_ec_pr_freq, 9.5);
1725    }
1726
1727    #[test]
1728    fn parse_reticulum_ingress_and_egress_defaults_reject_invalid_values() {
1729        for (key, value) in [
1730            ("ic_max_held_announces", "-1"),
1731            ("ic_burst_hold", "-1"),
1732            ("ic_burst_hold", "NaN"),
1733            ("ic_burst_freq_new", "-1"),
1734            ("ic_burst_freq", "inf"),
1735            ("ic_pr_burst_freq_new", "-1"),
1736            ("ic_pr_burst_freq", "NaN"),
1737            ("ic_new_time", "-1"),
1738            ("ic_burst_penalty", "-1"),
1739            ("ic_held_release_interval", "-1"),
1740            ("ec_pr_freq", "-1"),
1741            ("ec_pr_freq", "inf"),
1742        ] {
1743            let input = format!("[reticulum]\n{key} = {value}\n");
1744            let err = parse(&input).unwrap_err();
1745            assert!(
1746                format!("{err}").contains(key),
1747                "error {err} should mention {key}"
1748            );
1749        }
1750    }
1751
1752    #[test]
1753    fn parse_backbone_peer_pool_defaults_disabled() {
1754        let config = parse("[reticulum]\n").unwrap();
1755        assert_eq!(config.reticulum.backbone_peer_pool_max_connected, 0);
1756        assert_eq!(config.reticulum.backbone_peer_pool_failure_threshold, 3);
1757        assert_eq!(config.reticulum.backbone_peer_pool_failure_window, 600);
1758        assert_eq!(config.reticulum.backbone_peer_pool_cooldown, 900);
1759    }
1760
1761    #[test]
1762    fn parse_announce_table_limits_reject_zero() {
1763        let err = parse(
1764            r#"
1765[reticulum]
1766announce_table_ttl = 0
1767"#,
1768        )
1769        .unwrap_err();
1770        assert!(matches!(
1771            err,
1772            ConfigError::InvalidValue { key, .. } if key == "announce_table_ttl"
1773        ));
1774
1775        let err = parse(
1776            r#"
1777[reticulum]
1778known_destinations_max_entries = 0
1779"#,
1780        )
1781        .unwrap_err();
1782        assert!(matches!(
1783            err,
1784            ConfigError::InvalidValue { key, .. } if key == "known_destinations_max_entries"
1785        ));
1786
1787        let err = parse(
1788            r#"
1789[reticulum]
1790ratchet_expiry = 0
1791"#,
1792        )
1793        .unwrap_err();
1794        assert!(matches!(
1795            err,
1796            ConfigError::InvalidValue { key, .. } if key == "ratchet_expiry"
1797        ));
1798
1799        let err = parse(
1800            r#"
1801[reticulum]
1802announce_table_max_bytes = 0
1803"#,
1804        )
1805        .unwrap_err();
1806        assert!(matches!(
1807            err,
1808            ConfigError::InvalidValue { key, .. } if key == "announce_table_max_bytes"
1809        ));
1810
1811        let err = parse(
1812            r#"
1813[reticulum]
1814announce_queue_max_entries = 0
1815"#,
1816        )
1817        .unwrap_err();
1818        assert!(matches!(
1819            err,
1820            ConfigError::InvalidValue { key, .. } if key == "announce_queue_max_entries"
1821        ));
1822
1823        let err = parse(
1824            r#"
1825[reticulum]
1826announce_queue_max_interfaces = 0
1827"#,
1828        )
1829        .unwrap_err();
1830        assert!(matches!(
1831            err,
1832            ConfigError::InvalidValue { key, .. } if key == "announce_queue_max_interfaces"
1833        ));
1834
1835        let err = parse(
1836            r#"
1837[reticulum]
1838announce_queue_max_bytes = 0
1839"#,
1840        )
1841        .unwrap_err();
1842        assert!(matches!(
1843            err,
1844            ConfigError::InvalidValue { key, .. } if key == "announce_queue_max_bytes"
1845        ));
1846
1847        let err = parse(
1848            r#"
1849[reticulum]
1850driver_event_queue_capacity = 0
1851"#,
1852        )
1853        .unwrap_err();
1854        assert!(matches!(
1855            err,
1856            ConfigError::InvalidValue { key, .. } if key == "driver_event_queue_capacity"
1857        ));
1858
1859        let err = parse(
1860            r#"
1861[reticulum]
1862interface_writer_queue_capacity = 0
1863"#,
1864        )
1865        .unwrap_err();
1866        assert!(matches!(
1867            err,
1868            ConfigError::InvalidValue { key, .. } if key == "interface_writer_queue_capacity"
1869        ));
1870
1871        let err = parse(
1872            r#"
1873[reticulum]
1874announce_queue_ttl = 0
1875"#,
1876        )
1877        .unwrap_err();
1878        assert!(matches!(
1879            err,
1880            ConfigError::InvalidValue { key, .. } if key == "announce_queue_ttl"
1881        ));
1882    }
1883
1884    #[test]
1885    fn parse_announce_queue_overflow_policy_rejects_invalid() {
1886        let err = parse(
1887            r#"
1888[reticulum]
1889announce_queue_overflow_policy = keep_everything
1890"#,
1891        )
1892        .unwrap_err();
1893        assert!(matches!(
1894            err,
1895            ConfigError::InvalidValue { key, .. } if key == "announce_queue_overflow_policy"
1896        ));
1897    }
1898
1899    #[test]
1900    fn parse_destination_timeout_secs_alias() {
1901        let config = parse(
1902            r#"
1903[reticulum]
1904destination_timeout_secs = 777
1905"#,
1906        )
1907        .unwrap();
1908
1909        assert_eq!(config.reticulum.known_destinations_ttl, 777);
1910    }
1911
1912    #[test]
1913    fn parse_logging_section() {
1914        let input = "[logging]\nloglevel = 6\nlogtimestamps = no\n";
1915        let config = parse(input).unwrap();
1916        assert_eq!(config.logging.loglevel, 6);
1917        assert!(!config.logging.logtimestamps);
1918    }
1919
1920    #[test]
1921    fn parse_logging_accepts_pathing_and_extreme_levels() {
1922        for level in [7, 8] {
1923            let config = parse(&format!("[logging]\nloglevel = {level}\n")).unwrap();
1924            assert_eq!(config.logging.loglevel, level);
1925        }
1926    }
1927
1928    #[test]
1929    fn parse_logging_rejects_levels_above_extreme() {
1930        for value in ["9", "255", "256", "-1"] {
1931            let err = parse(&format!("[logging]\nloglevel = {value}\n")).unwrap_err();
1932            assert!(matches!(
1933                err,
1934                ConfigError::InvalidValue { key, .. } if key == "loglevel"
1935            ));
1936        }
1937    }
1938
1939    #[test]
1940    fn parse_logging_rejects_invalid_logtimestamps() {
1941        let err = parse("[logging]\nlogtimestamps = maybe\n").unwrap_err();
1942        assert!(matches!(
1943            err,
1944            ConfigError::InvalidValue { key, .. } if key == "logtimestamps"
1945        ));
1946    }
1947
1948    #[test]
1949    fn parse_interface_tcp_client() {
1950        let input = r#"
1951[interfaces]
1952  [[TCP Client]]
1953    type = TCPClientInterface
1954    enabled = Yes
1955    target_host = 87.106.8.245
1956    target_port = 4242
1957"#;
1958        let config = parse(input).unwrap();
1959        assert_eq!(config.interfaces.len(), 1);
1960        let iface = &config.interfaces[0];
1961        assert_eq!(iface.name, "TCP Client");
1962        assert_eq!(iface.interface_type, "TCPClientInterface");
1963        assert!(iface.enabled);
1964        assert_eq!(iface.params.get("target_host").unwrap(), "87.106.8.245");
1965        assert_eq!(iface.params.get("target_port").unwrap(), "4242");
1966    }
1967
1968    #[test]
1969    fn parse_interface_tcp_server() {
1970        let input = r#"
1971[interfaces]
1972  [[TCP Server]]
1973    type = TCPServerInterface
1974    enabled = Yes
1975    listen_ip = 0.0.0.0
1976    listen_port = 4242
1977"#;
1978        let config = parse(input).unwrap();
1979        assert_eq!(config.interfaces.len(), 1);
1980        let iface = &config.interfaces[0];
1981        assert_eq!(iface.name, "TCP Server");
1982        assert_eq!(iface.interface_type, "TCPServerInterface");
1983        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
1984        assert_eq!(iface.params.get("listen_port").unwrap(), "4242");
1985    }
1986
1987    #[test]
1988    fn parse_interface_udp() {
1989        let input = r#"
1990[interfaces]
1991  [[UDP Interface]]
1992    type = UDPInterface
1993    enabled = Yes
1994    listen_ip = 0.0.0.0
1995    listen_port = 4242
1996    forward_ip = 255.255.255.255
1997    forward_port = 4242
1998"#;
1999        let config = parse(input).unwrap();
2000        assert_eq!(config.interfaces.len(), 1);
2001        let iface = &config.interfaces[0];
2002        assert_eq!(iface.name, "UDP Interface");
2003        assert_eq!(iface.interface_type, "UDPInterface");
2004        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
2005        assert_eq!(iface.params.get("forward_ip").unwrap(), "255.255.255.255");
2006    }
2007
2008    #[test]
2009    fn parse_multiple_interfaces() {
2010        let input = r#"
2011[interfaces]
2012  [[TCP Client]]
2013    type = TCPClientInterface
2014    target_host = 10.0.0.1
2015    target_port = 4242
2016
2017  [[UDP Broadcast]]
2018    type = UDPInterface
2019    listen_ip = 0.0.0.0
2020    listen_port = 5555
2021    forward_ip = 255.255.255.255
2022    forward_port = 5555
2023"#;
2024        let config = parse(input).unwrap();
2025        assert_eq!(config.interfaces.len(), 2);
2026        assert_eq!(config.interfaces[0].name, "TCP Client");
2027        assert_eq!(config.interfaces[0].interface_type, "TCPClientInterface");
2028        assert_eq!(config.interfaces[1].name, "UDP Broadcast");
2029        assert_eq!(config.interfaces[1].interface_type, "UDPInterface");
2030    }
2031
2032    #[test]
2033    fn parse_third_level_interface_sections() {
2034        let config = parse(
2035            "[interfaces]\n[[radio]]\n  type = RNodeMultiInterface\n  port = /dev/ttyUSB0\n  [[[fast]]]\n    enabled = yes\n    vport = 7\n    frequency = 915000000\n  [[[disabled]]]\n    enabled = no\n    vport = 2\n",
2036        )
2037        .unwrap();
2038        assert_eq!(config.interfaces.len(), 1);
2039        let interface = &config.interfaces[0];
2040        assert_eq!(interface.params["port"], "/dev/ttyUSB0");
2041        assert_eq!(interface.subinterfaces.len(), 2);
2042        assert_eq!(interface.subinterfaces[0].name, "fast");
2043        assert_eq!(interface.subinterfaces[0].params["vport"], "7");
2044    }
2045
2046    #[test]
2047    fn parse_booleans() {
2048        // Test all boolean variants
2049        for (input, expected) in &[
2050            ("Yes", true),
2051            ("No", false),
2052            ("True", true),
2053            ("False", false),
2054            ("true", true),
2055            ("false", false),
2056            ("1", true),
2057            ("0", false),
2058            ("on", true),
2059            ("off", false),
2060        ] {
2061            let result = parse_bool(input);
2062            assert_eq!(result, Some(*expected), "parse_bool({}) failed", input);
2063        }
2064    }
2065
2066    #[test]
2067    fn parse_comments() {
2068        let input = r#"
2069# This is a comment
2070[reticulum]
2071enable_transport = True  # inline comment
2072# share_instance = No
2073instance_name = test
2074"#;
2075        let config = parse(input).unwrap();
2076        assert!(config.reticulum.enable_transport);
2077        assert!(config.reticulum.share_instance); // commented out line should be ignored
2078        assert_eq!(config.reticulum.instance_name, "test");
2079    }
2080
2081    #[test]
2082    fn parse_interface_mode_field() {
2083        let input = r#"
2084[interfaces]
2085  [[TCP Client]]
2086    type = TCPClientInterface
2087    interface_mode = access_point
2088    target_host = 10.0.0.1
2089    target_port = 4242
2090"#;
2091        let config = parse(input).unwrap();
2092        assert_eq!(config.interfaces[0].mode, "access_point");
2093    }
2094
2095    #[test]
2096    fn parse_mode_fallback() {
2097        // Python also accepts "mode" as fallback for "interface_mode"
2098        let input = r#"
2099[interfaces]
2100  [[TCP Client]]
2101    type = TCPClientInterface
2102    mode = gateway
2103    target_host = 10.0.0.1
2104    target_port = 4242
2105"#;
2106        let config = parse(input).unwrap();
2107        assert_eq!(config.interfaces[0].mode, "gateway");
2108    }
2109
2110    #[test]
2111    fn parse_interface_mode_takes_precedence() {
2112        // If both interface_mode and mode are set, interface_mode wins
2113        let input = r#"
2114[interfaces]
2115  [[TCP Client]]
2116    type = TCPClientInterface
2117    interface_mode = roaming
2118    mode = boundary
2119    target_host = 10.0.0.1
2120    target_port = 4242
2121"#;
2122        let config = parse(input).unwrap();
2123        assert_eq!(config.interfaces[0].mode, "roaming");
2124    }
2125
2126    #[test]
2127    fn parse_disabled_interface() {
2128        let input = r#"
2129[interfaces]
2130  [[Disabled TCP]]
2131    type = TCPClientInterface
2132    enabled = No
2133    target_host = 10.0.0.1
2134    target_port = 4242
2135"#;
2136        let config = parse(input).unwrap();
2137        assert_eq!(config.interfaces.len(), 1);
2138        assert!(!config.interfaces[0].enabled);
2139    }
2140
2141    #[test]
2142    fn parse_serial_interface() {
2143        let input = r#"
2144[interfaces]
2145  [[Serial Port]]
2146    type = SerialInterface
2147    enabled = Yes
2148    port = /dev/ttyUSB0
2149    speed = 115200
2150    databits = 8
2151    parity = N
2152    stopbits = 1
2153"#;
2154        let config = parse(input).unwrap();
2155        assert_eq!(config.interfaces.len(), 1);
2156        let iface = &config.interfaces[0];
2157        assert_eq!(iface.name, "Serial Port");
2158        assert_eq!(iface.interface_type, "SerialInterface");
2159        assert!(iface.enabled);
2160        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB0");
2161        assert_eq!(iface.params.get("speed").unwrap(), "115200");
2162        assert_eq!(iface.params.get("databits").unwrap(), "8");
2163        assert_eq!(iface.params.get("parity").unwrap(), "N");
2164        assert_eq!(iface.params.get("stopbits").unwrap(), "1");
2165    }
2166
2167    #[test]
2168    fn parse_kiss_interface() {
2169        let input = r#"
2170[interfaces]
2171  [[KISS TNC]]
2172    type = KISSInterface
2173    enabled = Yes
2174    port = /dev/ttyUSB1
2175    speed = 9600
2176    preamble = 350
2177    txtail = 20
2178    persistence = 64
2179    slottime = 20
2180    flow_control = True
2181    id_interval = 600
2182    id_callsign = MYCALL
2183"#;
2184        let config = parse(input).unwrap();
2185        assert_eq!(config.interfaces.len(), 1);
2186        let iface = &config.interfaces[0];
2187        assert_eq!(iface.name, "KISS TNC");
2188        assert_eq!(iface.interface_type, "KISSInterface");
2189        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB1");
2190        assert_eq!(iface.params.get("speed").unwrap(), "9600");
2191        assert_eq!(iface.params.get("preamble").unwrap(), "350");
2192        assert_eq!(iface.params.get("txtail").unwrap(), "20");
2193        assert_eq!(iface.params.get("persistence").unwrap(), "64");
2194        assert_eq!(iface.params.get("slottime").unwrap(), "20");
2195        assert_eq!(iface.params.get("flow_control").unwrap(), "True");
2196        assert_eq!(iface.params.get("id_interval").unwrap(), "600");
2197        assert_eq!(iface.params.get("id_callsign").unwrap(), "MYCALL");
2198    }
2199
2200    #[test]
2201    fn parse_ifac_networkname() {
2202        let input = r#"
2203[interfaces]
2204  [[TCP Client]]
2205    type = TCPClientInterface
2206    target_host = 10.0.0.1
2207    target_port = 4242
2208    networkname = testnet
2209"#;
2210        let config = parse(input).unwrap();
2211        assert_eq!(
2212            config.interfaces[0].params.get("networkname").unwrap(),
2213            "testnet"
2214        );
2215    }
2216
2217    #[test]
2218    fn parse_ifac_passphrase() {
2219        let input = r#"
2220[interfaces]
2221  [[TCP Client]]
2222    type = TCPClientInterface
2223    target_host = 10.0.0.1
2224    target_port = 4242
2225    passphrase = secret123
2226    ifac_size = 64
2227"#;
2228        let config = parse(input).unwrap();
2229        assert_eq!(
2230            config.interfaces[0].params.get("passphrase").unwrap(),
2231            "secret123"
2232        );
2233        assert_eq!(config.interfaces[0].params.get("ifac_size").unwrap(), "64");
2234    }
2235
2236    #[test]
2237    fn parse_remote_management_config() {
2238        let input = r#"
2239[reticulum]
2240enable_transport = True
2241enable_remote_management = Yes
2242remote_management_allowed = aabbccdd00112233aabbccdd00112233, 11223344556677881122334455667788
2243publish_blackhole = Yes
2244"#;
2245        let config = parse(input).unwrap();
2246        assert!(config.reticulum.enable_remote_management);
2247        assert!(config.reticulum.publish_blackhole);
2248        assert_eq!(config.reticulum.remote_management_allowed.len(), 2);
2249        assert_eq!(
2250            config.reticulum.remote_management_allowed[0],
2251            "aabbccdd00112233aabbccdd00112233"
2252        );
2253        assert_eq!(
2254            config.reticulum.remote_management_allowed[1],
2255            "11223344556677881122334455667788"
2256        );
2257    }
2258
2259    #[test]
2260    fn parse_remote_management_defaults() {
2261        let input = "[reticulum]\n";
2262        let config = parse(input).unwrap();
2263        assert!(!config.reticulum.enable_remote_management);
2264        assert!(!config.reticulum.publish_blackhole);
2265        assert!(config.reticulum.remote_management_allowed.is_empty());
2266    }
2267
2268    #[test]
2269    fn parse_hooks_section() {
2270        let input = r#"
2271[hooks]
2272  [[drop_tick]]
2273    path = /tmp/drop_tick.wasm
2274    attach_point = Tick
2275    priority = 10
2276    enabled = Yes
2277
2278  [[log_announce]]
2279    path = /tmp/log_announce.wasm
2280    type = native
2281    attach_point = AnnounceReceived
2282    priority = 5
2283    enabled = No
2284
2285  [[builtin_tick]]
2286    builtin = example.tick
2287    type = builtin
2288    attach_point = Tick
2289"#;
2290        let config = parse(input).unwrap();
2291        assert_eq!(config.hooks.len(), 3);
2292        assert_eq!(config.hooks[0].name, "drop_tick");
2293        assert_eq!(config.hooks[0].path, "/tmp/drop_tick.wasm");
2294        assert_eq!(config.hooks[0].hook_type, default_hook_type());
2295        assert_eq!(config.hooks[0].attach_point, "Tick");
2296        assert_eq!(config.hooks[0].priority, 10);
2297        assert!(config.hooks[0].enabled);
2298        assert_eq!(config.hooks[1].name, "log_announce");
2299        assert_eq!(config.hooks[1].hook_type, "native");
2300        assert_eq!(config.hooks[1].attach_point, "AnnounceReceived");
2301        assert!(!config.hooks[1].enabled);
2302        assert_eq!(config.hooks[2].hook_type, "builtin");
2303        assert_eq!(config.hooks[2].builtin_id.as_deref(), Some("example.tick"));
2304    }
2305
2306    #[test]
2307    fn parse_empty_hooks() {
2308        let input = "[hooks]\n";
2309        let config = parse(input).unwrap();
2310        assert!(config.hooks.is_empty());
2311    }
2312
2313    #[test]
2314    fn parse_hook_point_names() {
2315        assert_eq!(parse_hook_point("PreIngress"), Some(0));
2316        assert_eq!(parse_hook_point("PreDispatch"), Some(1));
2317        assert_eq!(parse_hook_point("AnnounceReceived"), Some(2));
2318        assert_eq!(parse_hook_point("PathUpdated"), Some(3));
2319        assert_eq!(parse_hook_point("AnnounceRetransmit"), Some(4));
2320        assert_eq!(parse_hook_point("LinkRequestReceived"), Some(5));
2321        assert_eq!(parse_hook_point("LinkEstablished"), Some(6));
2322        assert_eq!(parse_hook_point("LinkClosed"), Some(7));
2323        assert_eq!(parse_hook_point("InterfaceUp"), Some(8));
2324        assert_eq!(parse_hook_point("InterfaceDown"), Some(9));
2325        assert_eq!(parse_hook_point("InterfaceConfigChanged"), Some(10));
2326        assert_eq!(parse_hook_point("BackbonePeerConnected"), Some(11));
2327        assert_eq!(parse_hook_point("BackbonePeerDisconnected"), Some(12));
2328        assert_eq!(parse_hook_point("BackbonePeerIdleTimeout"), Some(13));
2329        assert_eq!(parse_hook_point("BackbonePeerWriteStall"), Some(14));
2330        assert_eq!(parse_hook_point("BackbonePeerPenalty"), Some(15));
2331        assert_eq!(parse_hook_point("SendOnInterface"), Some(16));
2332        assert_eq!(parse_hook_point("BroadcastOnAllInterfaces"), Some(17));
2333        assert_eq!(parse_hook_point("DeliverLocal"), Some(18));
2334        assert_eq!(parse_hook_point("TunnelSynthesize"), Some(19));
2335        assert_eq!(parse_hook_point("Tick"), Some(20));
2336        assert_eq!(parse_hook_point("Unknown"), None);
2337    }
2338
2339    #[test]
2340    fn backbone_extra_params_preserved() {
2341        let config = r#"
2342[reticulum]
2343enable_transport = True
2344
2345[interfaces]
2346  [[Public Entrypoint]]
2347    type = BackboneInterface
2348    enabled = yes
2349    listen_ip = 0.0.0.0
2350    listen_port = 4242
2351    interface_mode = gateway
2352    discoverable = Yes
2353    discovery_name = PizzaSpaghettiMandolino
2354    announce_interval = 600
2355    discovery_stamp_value = 24
2356    reachable_on = 87.106.8.245
2357"#;
2358        let parsed = parse(config).unwrap();
2359        assert_eq!(parsed.interfaces.len(), 1);
2360        let iface = &parsed.interfaces[0];
2361        assert_eq!(iface.name, "Public Entrypoint");
2362        assert_eq!(iface.interface_type, "BackboneInterface");
2363        // After removing type, enabled, interface_mode, remaining params should include discovery keys
2364        assert_eq!(
2365            iface.params.get("discoverable").map(|s| s.as_str()),
2366            Some("Yes")
2367        );
2368        assert_eq!(
2369            iface.params.get("discovery_name").map(|s| s.as_str()),
2370            Some("PizzaSpaghettiMandolino")
2371        );
2372        assert_eq!(
2373            iface.params.get("announce_interval").map(|s| s.as_str()),
2374            Some("600")
2375        );
2376        assert_eq!(
2377            iface
2378                .params
2379                .get("discovery_stamp_value")
2380                .map(|s| s.as_str()),
2381            Some("24")
2382        );
2383        assert_eq!(
2384            iface.params.get("reachable_on").map(|s| s.as_str()),
2385            Some("87.106.8.245")
2386        );
2387        assert_eq!(
2388            iface.params.get("listen_ip").map(|s| s.as_str()),
2389            Some("0.0.0.0")
2390        );
2391        assert_eq!(
2392            iface.params.get("listen_port").map(|s| s.as_str()),
2393            Some("4242")
2394        );
2395    }
2396
2397    #[test]
2398    fn parse_probe_protocol() {
2399        let input = r#"
2400[reticulum]
2401probe_addr = 1.2.3.4:19302
2402probe_protocol = stun
2403"#;
2404        let config = parse(input).unwrap();
2405        assert_eq!(
2406            config.reticulum.probe_addr.as_deref(),
2407            Some("1.2.3.4:19302")
2408        );
2409        assert_eq!(config.reticulum.probe_protocol.as_deref(), Some("stun"));
2410    }
2411
2412    #[test]
2413    fn parse_probe_protocol_defaults_to_none() {
2414        let input = r#"
2415[reticulum]
2416probe_addr = 1.2.3.4:4343
2417"#;
2418        let config = parse(input).unwrap();
2419        assert_eq!(config.reticulum.probe_addr.as_deref(), Some("1.2.3.4:4343"));
2420        assert!(config.reticulum.probe_protocol.is_none());
2421    }
2422
2423    #[test]
2424    fn parse_direct_connect_policy() {
2425        let config = parse(
2426            r#"
2427[reticulum]
2428direct_connect_policy = identified_only
2429"#,
2430        )
2431        .unwrap();
2432        assert_eq!(
2433            config.reticulum.direct_connect_policy,
2434            crate::event::HolePunchPolicy::IdentifiedOnly
2435        );
2436    }
2437
2438    #[test]
2439    fn direct_connect_policy_defaults_to_identified_only_and_rejects_invalid() {
2440        let config = parse("[reticulum]\n").unwrap();
2441        assert_eq!(
2442            config.reticulum.direct_connect_policy,
2443            crate::event::HolePunchPolicy::IdentifiedOnly
2444        );
2445        let config = parse("[reticulum]\ndirect_connect_policy = accept_all\n").unwrap();
2446        assert_eq!(
2447            config.reticulum.direct_connect_policy,
2448            crate::event::HolePunchPolicy::AcceptAll
2449        );
2450        assert!(parse("[reticulum]\ndirect_connect_policy = sometimes\n").is_err());
2451    }
2452
2453    #[test]
2454    fn direct_connect_allow_private_endpoints_parses_and_defaults_false() {
2455        let config = parse("[reticulum]\n").unwrap();
2456        assert!(!config.reticulum.direct_connect_allow_private_endpoints);
2457        let config = parse("[reticulum]\ndirect_connect_allow_private_endpoints = yes\n").unwrap();
2458        assert!(config.reticulum.direct_connect_allow_private_endpoints);
2459        let config =
2460            parse("[reticulum]\ndirect_connect_allow_private_endpoints = False\n").unwrap();
2461        assert!(!config.reticulum.direct_connect_allow_private_endpoints);
2462        assert!(parse("[reticulum]\ndirect_connect_allow_private_endpoints = maybe\n").is_err());
2463    }
2464
2465    #[test]
2466    fn link_mtu_discovery_defaults_true_and_honors_both_boolean_values() {
2467        assert!(parse_link_mtu_discovery("").unwrap());
2468        assert!(parse_link_mtu_discovery("[reticulum]\nlink_mtu_discovery = Yes\n").unwrap());
2469        assert!(!parse_link_mtu_discovery("[reticulum]\nlink_mtu_discovery = False\n").unwrap());
2470        assert!(parse_link_mtu_discovery("[logging]\nlink_mtu_discovery = No\n").unwrap());
2471        assert!(parse_link_mtu_discovery("[reticulum]\nlink_mtu_discovery = maybe\n").is_err());
2472    }
2473}