Skip to main content

rns_net/
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::path::Path;
10use std::io;
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    /// Network interface to bind outbound sockets to (e.g. "usb0").
49    pub device: Option<String>,
50    /// Enable interface discovery (advertise discoverable interfaces and
51    /// listen for discovery announces from the network).
52    pub discover_interfaces: bool,
53    /// Minimum stamp value for accepting discovered interfaces.
54    pub required_discovery_value: Option<u8>,
55}
56
57impl Default for ReticulumSection {
58    fn default() -> Self {
59        ReticulumSection {
60            enable_transport: false,
61            share_instance: true,
62            instance_name: "default".into(),
63            shared_instance_port: 37428,
64            instance_control_port: 37429,
65            panic_on_interface_error: false,
66            use_implicit_proof: true,
67            network_identity: None,
68            respond_to_probes: false,
69            enable_remote_management: false,
70            remote_management_allowed: Vec::new(),
71            publish_blackhole: false,
72            probe_port: None,
73            probe_addr: None,
74            device: None,
75            discover_interfaces: false,
76            required_discovery_value: None,
77        }
78    }
79}
80
81/// The `[logging]` section.
82#[derive(Debug, Clone)]
83pub struct LoggingSection {
84    pub loglevel: u8,
85}
86
87impl Default for LoggingSection {
88    fn default() -> Self {
89        LoggingSection { loglevel: 4 }
90    }
91}
92
93/// A parsed interface from `[[subsection]]` within `[interfaces]`.
94#[derive(Debug, Clone)]
95pub struct ParsedInterface {
96    pub name: String,
97    pub interface_type: String,
98    pub enabled: bool,
99    pub mode: String,
100    pub params: HashMap<String, String>,
101}
102
103/// Configuration parse error.
104#[derive(Debug, Clone)]
105pub enum ConfigError {
106    Io(String),
107    Parse(String),
108    InvalidValue { key: String, value: String },
109}
110
111impl fmt::Display for ConfigError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            ConfigError::Io(msg) => write!(f, "Config I/O error: {}", msg),
115            ConfigError::Parse(msg) => write!(f, "Config parse error: {}", msg),
116            ConfigError::InvalidValue { key, value } => {
117                write!(f, "Invalid value for '{}': '{}'", key, value)
118            }
119        }
120    }
121}
122
123impl From<io::Error> for ConfigError {
124    fn from(e: io::Error) -> Self {
125        ConfigError::Io(e.to_string())
126    }
127}
128
129/// Parse a config string into an `RnsConfig`.
130pub fn parse(input: &str) -> Result<RnsConfig, ConfigError> {
131    let mut current_section: Option<String> = None;
132    let mut current_subsection: Option<String> = None;
133
134    let mut reticulum_kvs: HashMap<String, String> = HashMap::new();
135    let mut logging_kvs: HashMap<String, String> = HashMap::new();
136    let mut interfaces: Vec<ParsedInterface> = Vec::new();
137    let mut current_iface_kvs: Option<HashMap<String, String>> = None;
138    let mut current_iface_name: Option<String> = None;
139    let mut hooks: Vec<ParsedHook> = Vec::new();
140    let mut current_hook_kvs: Option<HashMap<String, String>> = None;
141    let mut current_hook_name: Option<String> = None;
142
143    for line in input.lines() {
144        // Strip comments (# to end of line, unless inside quotes)
145        let line = strip_comment(line);
146        let trimmed = line.trim();
147
148        // Skip empty lines
149        if trimmed.is_empty() {
150            continue;
151        }
152
153        // Check for subsection [[name]]
154        if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
155            let name = trimmed[2..trimmed.len() - 2].trim().to_string();
156            // Finalize previous interface subsection if any
157            if let (Some(iface_name), Some(kvs)) =
158                (current_iface_name.take(), current_iface_kvs.take())
159            {
160                interfaces.push(build_parsed_interface(iface_name, kvs));
161            }
162            // Finalize previous hook subsection if any
163            if let (Some(hook_name), Some(kvs)) =
164                (current_hook_name.take(), current_hook_kvs.take())
165            {
166                hooks.push(build_parsed_hook(hook_name, kvs));
167            }
168            current_subsection = Some(name.clone());
169            // Determine which section we're in to know subsection type
170            if current_section.as_deref() == Some("hooks") {
171                current_hook_name = Some(name);
172                current_hook_kvs = Some(HashMap::new());
173            } else {
174                current_iface_name = Some(name);
175                current_iface_kvs = Some(HashMap::new());
176            }
177            continue;
178        }
179
180        // Check for section [name]
181        if trimmed.starts_with('[') && trimmed.ends_with(']') {
182            // Finalize previous interface subsection if any
183            if let (Some(iface_name), Some(kvs)) =
184                (current_iface_name.take(), current_iface_kvs.take())
185            {
186                interfaces.push(build_parsed_interface(iface_name, kvs));
187            }
188            // Finalize previous hook subsection if any
189            if let (Some(hook_name), Some(kvs)) =
190                (current_hook_name.take(), current_hook_kvs.take())
191            {
192                hooks.push(build_parsed_hook(hook_name, kvs));
193            }
194            current_subsection = None;
195
196            let name = trimmed[1..trimmed.len() - 1].trim().to_lowercase();
197            current_section = Some(name);
198            continue;
199        }
200
201        // Parse key = value
202        if let Some(eq_pos) = trimmed.find('=') {
203            let key = trimmed[..eq_pos].trim().to_string();
204            let value = trimmed[eq_pos + 1..].trim().to_string();
205
206            if current_subsection.is_some() {
207                // Inside a [[subsection]] — exactly one of these should be Some
208                debug_assert!(
209                    !(current_hook_kvs.is_some() && current_iface_kvs.is_some()),
210                    "hook and interface subsections should never be active simultaneously"
211                );
212                if let Some(ref mut kvs) = current_hook_kvs {
213                    kvs.insert(key, value);
214                } else if let Some(ref mut kvs) = current_iface_kvs {
215                    kvs.insert(key, value);
216                }
217            } else if let Some(ref section) = current_section {
218                match section.as_str() {
219                    "reticulum" => {
220                        reticulum_kvs.insert(key, value);
221                    }
222                    "logging" => {
223                        logging_kvs.insert(key, value);
224                    }
225                    _ => {} // ignore unknown sections
226                }
227            }
228        }
229    }
230
231    // Finalize last subsections
232    if let (Some(iface_name), Some(kvs)) = (current_iface_name.take(), current_iface_kvs.take()) {
233        interfaces.push(build_parsed_interface(iface_name, kvs));
234    }
235    if let (Some(hook_name), Some(kvs)) = (current_hook_name.take(), current_hook_kvs.take()) {
236        hooks.push(build_parsed_hook(hook_name, kvs));
237    }
238
239    // Build typed sections
240    let reticulum = build_reticulum_section(&reticulum_kvs)?;
241    let logging = build_logging_section(&logging_kvs)?;
242
243    Ok(RnsConfig {
244        reticulum,
245        logging,
246        interfaces,
247        hooks,
248    })
249}
250
251/// Parse a config file from disk.
252pub fn parse_file(path: &Path) -> Result<RnsConfig, ConfigError> {
253    let content = std::fs::read_to_string(path)?;
254    parse(&content)
255}
256
257/// Strip `#` comments from a line (simple: not inside quotes).
258fn strip_comment(line: &str) -> &str {
259    // Find # that is not inside quotes
260    let mut in_quote = false;
261    let mut quote_char = '"';
262    for (i, ch) in line.char_indices() {
263        if !in_quote && (ch == '"' || ch == '\'') {
264            in_quote = true;
265            quote_char = ch;
266        } else if in_quote && ch == quote_char {
267            in_quote = false;
268        } else if !in_quote && ch == '#' {
269            return &line[..i];
270        }
271    }
272    line
273}
274
275/// Parse a string as a boolean (ConfigObj style). Public API for use by node.rs.
276pub fn parse_bool_pub(value: &str) -> Option<bool> {
277    parse_bool(value)
278}
279
280/// Parse a string as a boolean (ConfigObj style).
281fn parse_bool(value: &str) -> Option<bool> {
282    match value.to_lowercase().as_str() {
283        "yes" | "true" | "1" | "on" => Some(true),
284        "no" | "false" | "0" | "off" => Some(false),
285        _ => None,
286    }
287}
288
289fn build_parsed_interface(name: String, mut kvs: HashMap<String, String>) -> ParsedInterface {
290    let interface_type = kvs.remove("type").unwrap_or_default();
291    let enabled = kvs
292        .remove("enabled")
293        .and_then(|v| parse_bool(&v))
294        .unwrap_or(true);
295    // Python checks `interface_mode` first, then falls back to `mode`
296    let mode = kvs
297        .remove("interface_mode")
298        .or_else(|| kvs.remove("mode"))
299        .unwrap_or_else(|| "full".into());
300
301    ParsedInterface {
302        name,
303        interface_type,
304        enabled,
305        mode,
306        params: kvs,
307    }
308}
309
310fn build_parsed_hook(name: String, mut kvs: HashMap<String, String>) -> ParsedHook {
311    let path = kvs.remove("path").unwrap_or_default();
312    let attach_point = kvs.remove("attach_point").unwrap_or_default();
313    let priority = kvs
314        .remove("priority")
315        .and_then(|v| v.parse::<i32>().ok())
316        .unwrap_or(0);
317    let enabled = kvs
318        .remove("enabled")
319        .and_then(|v| parse_bool(&v))
320        .unwrap_or(true);
321
322    ParsedHook {
323        name,
324        path,
325        attach_point,
326        priority,
327        enabled,
328    }
329}
330
331/// Map a hook point name string to its index. Returns None for unknown names.
332pub fn parse_hook_point(s: &str) -> Option<usize> {
333    match s {
334        "PreIngress" => Some(0),
335        "PreDispatch" => Some(1),
336        "AnnounceReceived" => Some(2),
337        "PathUpdated" => Some(3),
338        "AnnounceRetransmit" => Some(4),
339        "LinkRequestReceived" => Some(5),
340        "LinkEstablished" => Some(6),
341        "LinkClosed" => Some(7),
342        "InterfaceUp" => Some(8),
343        "InterfaceDown" => Some(9),
344        "InterfaceConfigChanged" => Some(10),
345        "SendOnInterface" => Some(11),
346        "BroadcastOnAllInterfaces" => Some(12),
347        "DeliverLocal" => Some(13),
348        "TunnelSynthesize" => Some(14),
349        "Tick" => Some(15),
350        _ => None,
351    }
352}
353
354fn build_reticulum_section(
355    kvs: &HashMap<String, String>,
356) -> Result<ReticulumSection, ConfigError> {
357    let mut section = ReticulumSection::default();
358
359    if let Some(v) = kvs.get("enable_transport") {
360        section.enable_transport = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
361            key: "enable_transport".into(),
362            value: v.clone(),
363        })?;
364    }
365    if let Some(v) = kvs.get("share_instance") {
366        section.share_instance = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
367            key: "share_instance".into(),
368            value: v.clone(),
369        })?;
370    }
371    if let Some(v) = kvs.get("instance_name") {
372        section.instance_name = v.clone();
373    }
374    if let Some(v) = kvs.get("shared_instance_port") {
375        section.shared_instance_port =
376            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
377                key: "shared_instance_port".into(),
378                value: v.clone(),
379            })?;
380    }
381    if let Some(v) = kvs.get("instance_control_port") {
382        section.instance_control_port =
383            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
384                key: "instance_control_port".into(),
385                value: v.clone(),
386            })?;
387    }
388    if let Some(v) = kvs.get("panic_on_interface_error") {
389        section.panic_on_interface_error =
390            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
391                key: "panic_on_interface_error".into(),
392                value: v.clone(),
393            })?;
394    }
395    if let Some(v) = kvs.get("use_implicit_proof") {
396        section.use_implicit_proof = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
397            key: "use_implicit_proof".into(),
398            value: v.clone(),
399        })?;
400    }
401    if let Some(v) = kvs.get("network_identity") {
402        section.network_identity = Some(v.clone());
403    }
404    if let Some(v) = kvs.get("respond_to_probes") {
405        section.respond_to_probes = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
406            key: "respond_to_probes".into(),
407            value: v.clone(),
408        })?;
409    }
410    if let Some(v) = kvs.get("enable_remote_management") {
411        section.enable_remote_management = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
412            key: "enable_remote_management".into(),
413            value: v.clone(),
414        })?;
415    }
416    if let Some(v) = kvs.get("remote_management_allowed") {
417        // Value is a comma-separated list of hex identity hashes
418        for item in v.split(',') {
419            let trimmed = item.trim();
420            if !trimmed.is_empty() {
421                section.remote_management_allowed.push(trimmed.to_string());
422            }
423        }
424    }
425    if let Some(v) = kvs.get("publish_blackhole") {
426        section.publish_blackhole = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
427            key: "publish_blackhole".into(),
428            value: v.clone(),
429        })?;
430    }
431    if let Some(v) = kvs.get("probe_port") {
432        section.probe_port = Some(v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
433            key: "probe_port".into(),
434            value: v.clone(),
435        })?);
436    }
437    if let Some(v) = kvs.get("probe_addr") {
438        section.probe_addr = Some(v.clone());
439    }
440    if let Some(v) = kvs.get("device") {
441        section.device = Some(v.clone());
442    }
443    if let Some(v) = kvs.get("discover_interfaces") {
444        section.discover_interfaces = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
445            key: "discover_interfaces".into(),
446            value: v.clone(),
447        })?;
448    }
449    if let Some(v) = kvs.get("required_discovery_value") {
450        section.required_discovery_value = Some(v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
451            key: "required_discovery_value".into(),
452            value: v.clone(),
453        })?);
454    }
455
456    Ok(section)
457}
458
459fn build_logging_section(kvs: &HashMap<String, String>) -> Result<LoggingSection, ConfigError> {
460    let mut section = LoggingSection::default();
461
462    if let Some(v) = kvs.get("loglevel") {
463        section.loglevel = v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
464            key: "loglevel".into(),
465            value: v.clone(),
466        })?;
467    }
468
469    Ok(section)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn parse_empty() {
478        let config = parse("").unwrap();
479        assert!(!config.reticulum.enable_transport);
480        assert!(config.reticulum.share_instance);
481        assert_eq!(config.reticulum.instance_name, "default");
482        assert_eq!(config.logging.loglevel, 4);
483        assert!(config.interfaces.is_empty());
484    }
485
486    #[test]
487    fn parse_default_config() {
488        // The default config from Python's __default_rns_config__
489        let input = r#"
490[reticulum]
491enable_transport = False
492share_instance = Yes
493instance_name = default
494
495[logging]
496loglevel = 4
497
498[interfaces]
499
500  [[Default Interface]]
501    type = AutoInterface
502    enabled = Yes
503"#;
504        let config = parse(input).unwrap();
505        assert!(!config.reticulum.enable_transport);
506        assert!(config.reticulum.share_instance);
507        assert_eq!(config.reticulum.instance_name, "default");
508        assert_eq!(config.logging.loglevel, 4);
509        assert_eq!(config.interfaces.len(), 1);
510        assert_eq!(config.interfaces[0].name, "Default Interface");
511        assert_eq!(config.interfaces[0].interface_type, "AutoInterface");
512        assert!(config.interfaces[0].enabled);
513    }
514
515    #[test]
516    fn parse_reticulum_section() {
517        let input = r#"
518[reticulum]
519enable_transport = True
520share_instance = No
521instance_name = mynode
522shared_instance_port = 12345
523instance_control_port = 12346
524panic_on_interface_error = Yes
525use_implicit_proof = False
526respond_to_probes = True
527network_identity = /home/user/.reticulum/identity
528"#;
529        let config = parse(input).unwrap();
530        assert!(config.reticulum.enable_transport);
531        assert!(!config.reticulum.share_instance);
532        assert_eq!(config.reticulum.instance_name, "mynode");
533        assert_eq!(config.reticulum.shared_instance_port, 12345);
534        assert_eq!(config.reticulum.instance_control_port, 12346);
535        assert!(config.reticulum.panic_on_interface_error);
536        assert!(!config.reticulum.use_implicit_proof);
537        assert!(config.reticulum.respond_to_probes);
538        assert_eq!(
539            config.reticulum.network_identity.as_deref(),
540            Some("/home/user/.reticulum/identity")
541        );
542    }
543
544    #[test]
545    fn parse_logging_section() {
546        let input = "[logging]\nloglevel = 6\n";
547        let config = parse(input).unwrap();
548        assert_eq!(config.logging.loglevel, 6);
549    }
550
551    #[test]
552    fn parse_interface_tcp_client() {
553        let input = r#"
554[interfaces]
555  [[TCP Client]]
556    type = TCPClientInterface
557    enabled = Yes
558    target_host = 87.106.8.245
559    target_port = 4242
560"#;
561        let config = parse(input).unwrap();
562        assert_eq!(config.interfaces.len(), 1);
563        let iface = &config.interfaces[0];
564        assert_eq!(iface.name, "TCP Client");
565        assert_eq!(iface.interface_type, "TCPClientInterface");
566        assert!(iface.enabled);
567        assert_eq!(iface.params.get("target_host").unwrap(), "87.106.8.245");
568        assert_eq!(iface.params.get("target_port").unwrap(), "4242");
569    }
570
571    #[test]
572    fn parse_interface_tcp_server() {
573        let input = r#"
574[interfaces]
575  [[TCP Server]]
576    type = TCPServerInterface
577    enabled = Yes
578    listen_ip = 0.0.0.0
579    listen_port = 4242
580"#;
581        let config = parse(input).unwrap();
582        assert_eq!(config.interfaces.len(), 1);
583        let iface = &config.interfaces[0];
584        assert_eq!(iface.name, "TCP Server");
585        assert_eq!(iface.interface_type, "TCPServerInterface");
586        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
587        assert_eq!(iface.params.get("listen_port").unwrap(), "4242");
588    }
589
590    #[test]
591    fn parse_interface_udp() {
592        let input = r#"
593[interfaces]
594  [[UDP Interface]]
595    type = UDPInterface
596    enabled = Yes
597    listen_ip = 0.0.0.0
598    listen_port = 4242
599    forward_ip = 255.255.255.255
600    forward_port = 4242
601"#;
602        let config = parse(input).unwrap();
603        assert_eq!(config.interfaces.len(), 1);
604        let iface = &config.interfaces[0];
605        assert_eq!(iface.name, "UDP Interface");
606        assert_eq!(iface.interface_type, "UDPInterface");
607        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
608        assert_eq!(iface.params.get("forward_ip").unwrap(), "255.255.255.255");
609    }
610
611    #[test]
612    fn parse_multiple_interfaces() {
613        let input = r#"
614[interfaces]
615  [[TCP Client]]
616    type = TCPClientInterface
617    target_host = 10.0.0.1
618    target_port = 4242
619
620  [[UDP Broadcast]]
621    type = UDPInterface
622    listen_ip = 0.0.0.0
623    listen_port = 5555
624    forward_ip = 255.255.255.255
625    forward_port = 5555
626"#;
627        let config = parse(input).unwrap();
628        assert_eq!(config.interfaces.len(), 2);
629        assert_eq!(config.interfaces[0].name, "TCP Client");
630        assert_eq!(config.interfaces[0].interface_type, "TCPClientInterface");
631        assert_eq!(config.interfaces[1].name, "UDP Broadcast");
632        assert_eq!(config.interfaces[1].interface_type, "UDPInterface");
633    }
634
635    #[test]
636    fn parse_booleans() {
637        // Test all boolean variants
638        for (input, expected) in &[
639            ("Yes", true),
640            ("No", false),
641            ("True", true),
642            ("False", false),
643            ("true", true),
644            ("false", false),
645            ("1", true),
646            ("0", false),
647            ("on", true),
648            ("off", false),
649        ] {
650            let result = parse_bool(input);
651            assert_eq!(result, Some(*expected), "parse_bool({}) failed", input);
652        }
653    }
654
655    #[test]
656    fn parse_comments() {
657        let input = r#"
658# This is a comment
659[reticulum]
660enable_transport = True  # inline comment
661# share_instance = No
662instance_name = test
663"#;
664        let config = parse(input).unwrap();
665        assert!(config.reticulum.enable_transport);
666        assert!(config.reticulum.share_instance); // commented out line should be ignored
667        assert_eq!(config.reticulum.instance_name, "test");
668    }
669
670    #[test]
671    fn parse_interface_mode_field() {
672        let input = r#"
673[interfaces]
674  [[TCP Client]]
675    type = TCPClientInterface
676    interface_mode = access_point
677    target_host = 10.0.0.1
678    target_port = 4242
679"#;
680        let config = parse(input).unwrap();
681        assert_eq!(config.interfaces[0].mode, "access_point");
682    }
683
684    #[test]
685    fn parse_mode_fallback() {
686        // Python also accepts "mode" as fallback for "interface_mode"
687        let input = r#"
688[interfaces]
689  [[TCP Client]]
690    type = TCPClientInterface
691    mode = gateway
692    target_host = 10.0.0.1
693    target_port = 4242
694"#;
695        let config = parse(input).unwrap();
696        assert_eq!(config.interfaces[0].mode, "gateway");
697    }
698
699    #[test]
700    fn parse_interface_mode_takes_precedence() {
701        // If both interface_mode and mode are set, interface_mode wins
702        let input = r#"
703[interfaces]
704  [[TCP Client]]
705    type = TCPClientInterface
706    interface_mode = roaming
707    mode = boundary
708    target_host = 10.0.0.1
709    target_port = 4242
710"#;
711        let config = parse(input).unwrap();
712        assert_eq!(config.interfaces[0].mode, "roaming");
713    }
714
715    #[test]
716    fn parse_disabled_interface() {
717        let input = r#"
718[interfaces]
719  [[Disabled TCP]]
720    type = TCPClientInterface
721    enabled = No
722    target_host = 10.0.0.1
723    target_port = 4242
724"#;
725        let config = parse(input).unwrap();
726        assert_eq!(config.interfaces.len(), 1);
727        assert!(!config.interfaces[0].enabled);
728    }
729
730    #[test]
731    fn parse_serial_interface() {
732        let input = r#"
733[interfaces]
734  [[Serial Port]]
735    type = SerialInterface
736    enabled = Yes
737    port = /dev/ttyUSB0
738    speed = 115200
739    databits = 8
740    parity = N
741    stopbits = 1
742"#;
743        let config = parse(input).unwrap();
744        assert_eq!(config.interfaces.len(), 1);
745        let iface = &config.interfaces[0];
746        assert_eq!(iface.name, "Serial Port");
747        assert_eq!(iface.interface_type, "SerialInterface");
748        assert!(iface.enabled);
749        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB0");
750        assert_eq!(iface.params.get("speed").unwrap(), "115200");
751        assert_eq!(iface.params.get("databits").unwrap(), "8");
752        assert_eq!(iface.params.get("parity").unwrap(), "N");
753        assert_eq!(iface.params.get("stopbits").unwrap(), "1");
754    }
755
756    #[test]
757    fn parse_kiss_interface() {
758        let input = r#"
759[interfaces]
760  [[KISS TNC]]
761    type = KISSInterface
762    enabled = Yes
763    port = /dev/ttyUSB1
764    speed = 9600
765    preamble = 350
766    txtail = 20
767    persistence = 64
768    slottime = 20
769    flow_control = True
770    id_interval = 600
771    id_callsign = MYCALL
772"#;
773        let config = parse(input).unwrap();
774        assert_eq!(config.interfaces.len(), 1);
775        let iface = &config.interfaces[0];
776        assert_eq!(iface.name, "KISS TNC");
777        assert_eq!(iface.interface_type, "KISSInterface");
778        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB1");
779        assert_eq!(iface.params.get("speed").unwrap(), "9600");
780        assert_eq!(iface.params.get("preamble").unwrap(), "350");
781        assert_eq!(iface.params.get("txtail").unwrap(), "20");
782        assert_eq!(iface.params.get("persistence").unwrap(), "64");
783        assert_eq!(iface.params.get("slottime").unwrap(), "20");
784        assert_eq!(iface.params.get("flow_control").unwrap(), "True");
785        assert_eq!(iface.params.get("id_interval").unwrap(), "600");
786        assert_eq!(iface.params.get("id_callsign").unwrap(), "MYCALL");
787    }
788
789    #[test]
790    fn parse_ifac_networkname() {
791        let input = r#"
792[interfaces]
793  [[TCP Client]]
794    type = TCPClientInterface
795    target_host = 10.0.0.1
796    target_port = 4242
797    networkname = testnet
798"#;
799        let config = parse(input).unwrap();
800        assert_eq!(config.interfaces[0].params.get("networkname").unwrap(), "testnet");
801    }
802
803    #[test]
804    fn parse_ifac_passphrase() {
805        let input = r#"
806[interfaces]
807  [[TCP Client]]
808    type = TCPClientInterface
809    target_host = 10.0.0.1
810    target_port = 4242
811    passphrase = secret123
812    ifac_size = 64
813"#;
814        let config = parse(input).unwrap();
815        assert_eq!(config.interfaces[0].params.get("passphrase").unwrap(), "secret123");
816        assert_eq!(config.interfaces[0].params.get("ifac_size").unwrap(), "64");
817    }
818
819    #[test]
820    fn parse_remote_management_config() {
821        let input = r#"
822[reticulum]
823enable_transport = True
824enable_remote_management = Yes
825remote_management_allowed = aabbccdd00112233aabbccdd00112233, 11223344556677881122334455667788
826publish_blackhole = Yes
827"#;
828        let config = parse(input).unwrap();
829        assert!(config.reticulum.enable_remote_management);
830        assert!(config.reticulum.publish_blackhole);
831        assert_eq!(config.reticulum.remote_management_allowed.len(), 2);
832        assert_eq!(
833            config.reticulum.remote_management_allowed[0],
834            "aabbccdd00112233aabbccdd00112233"
835        );
836        assert_eq!(
837            config.reticulum.remote_management_allowed[1],
838            "11223344556677881122334455667788"
839        );
840    }
841
842    #[test]
843    fn parse_remote_management_defaults() {
844        let input = "[reticulum]\n";
845        let config = parse(input).unwrap();
846        assert!(!config.reticulum.enable_remote_management);
847        assert!(!config.reticulum.publish_blackhole);
848        assert!(config.reticulum.remote_management_allowed.is_empty());
849    }
850
851    #[test]
852    fn parse_hooks_section() {
853        let input = r#"
854[hooks]
855  [[drop_tick]]
856    path = /tmp/drop_tick.wasm
857    attach_point = Tick
858    priority = 10
859    enabled = Yes
860
861  [[log_announce]]
862    path = /tmp/log_announce.wasm
863    attach_point = AnnounceReceived
864    priority = 5
865    enabled = No
866"#;
867        let config = parse(input).unwrap();
868        assert_eq!(config.hooks.len(), 2);
869        assert_eq!(config.hooks[0].name, "drop_tick");
870        assert_eq!(config.hooks[0].path, "/tmp/drop_tick.wasm");
871        assert_eq!(config.hooks[0].attach_point, "Tick");
872        assert_eq!(config.hooks[0].priority, 10);
873        assert!(config.hooks[0].enabled);
874        assert_eq!(config.hooks[1].name, "log_announce");
875        assert_eq!(config.hooks[1].attach_point, "AnnounceReceived");
876        assert!(!config.hooks[1].enabled);
877    }
878
879    #[test]
880    fn parse_empty_hooks() {
881        let input = "[hooks]\n";
882        let config = parse(input).unwrap();
883        assert!(config.hooks.is_empty());
884    }
885
886    #[test]
887    fn parse_hook_point_names() {
888        assert_eq!(parse_hook_point("PreIngress"), Some(0));
889        assert_eq!(parse_hook_point("PreDispatch"), Some(1));
890        assert_eq!(parse_hook_point("AnnounceReceived"), Some(2));
891        assert_eq!(parse_hook_point("PathUpdated"), Some(3));
892        assert_eq!(parse_hook_point("AnnounceRetransmit"), Some(4));
893        assert_eq!(parse_hook_point("LinkRequestReceived"), Some(5));
894        assert_eq!(parse_hook_point("LinkEstablished"), Some(6));
895        assert_eq!(parse_hook_point("LinkClosed"), Some(7));
896        assert_eq!(parse_hook_point("InterfaceUp"), Some(8));
897        assert_eq!(parse_hook_point("InterfaceDown"), Some(9));
898        assert_eq!(parse_hook_point("InterfaceConfigChanged"), Some(10));
899        assert_eq!(parse_hook_point("SendOnInterface"), Some(11));
900        assert_eq!(parse_hook_point("BroadcastOnAllInterfaces"), Some(12));
901        assert_eq!(parse_hook_point("DeliverLocal"), Some(13));
902        assert_eq!(parse_hook_point("TunnelSynthesize"), Some(14));
903        assert_eq!(parse_hook_point("Tick"), Some(15));
904        assert_eq!(parse_hook_point("Unknown"), None);
905    }
906
907    #[test]
908    fn backbone_extra_params_preserved() {
909        let config = r#"
910[reticulum]
911enable_transport = True
912
913[interfaces]
914  [[Public Entrypoint]]
915    type = BackboneInterface
916    enabled = yes
917    listen_ip = 0.0.0.0
918    listen_port = 4242
919    interface_mode = gateway
920    discoverable = Yes
921    discovery_name = PizzaSpaghettiMandolino
922    announce_interval = 600
923    discovery_stamp_value = 24
924    reachable_on = 87.106.8.245
925"#;
926        let parsed = parse(config).unwrap();
927        assert_eq!(parsed.interfaces.len(), 1);
928        let iface = &parsed.interfaces[0];
929        assert_eq!(iface.name, "Public Entrypoint");
930        assert_eq!(iface.interface_type, "BackboneInterface");
931        // After removing type, enabled, interface_mode, remaining params should include discovery keys
932        assert_eq!(iface.params.get("discoverable").map(|s| s.as_str()), Some("Yes"));
933        assert_eq!(iface.params.get("discovery_name").map(|s| s.as_str()), Some("PizzaSpaghettiMandolino"));
934        assert_eq!(iface.params.get("announce_interval").map(|s| s.as_str()), Some("600"));
935        assert_eq!(iface.params.get("discovery_stamp_value").map(|s| s.as_str()), Some("24"));
936        assert_eq!(iface.params.get("reachable_on").map(|s| s.as_str()), Some("87.106.8.245"));
937        assert_eq!(iface.params.get("listen_ip").map(|s| s.as_str()), Some("0.0.0.0"));
938        assert_eq!(iface.params.get("listen_port").map(|s| s.as_str()), Some("4242"));
939    }
940}