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