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