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