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