Skip to main content

ant_node/
config.rs

1//! Configuration for ant-node.
2
3use serde::{Deserialize, Serialize};
4use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
5use std::path::{Path, PathBuf};
6
7/// Filename for the persisted node identity keypair.
8pub const NODE_IDENTITY_FILENAME: &str = "node_identity.key";
9
10/// Subdirectory under the root dir that contains per-node data directories.
11pub const NODES_SUBDIR: &str = "nodes";
12
13/// Upgrade channel for auto-updates.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum UpgradeChannel {
17    /// Stable releases only.
18    #[default]
19    Stable,
20    /// Beta releases (includes stable).
21    Beta,
22}
23
24/// Network mode for different deployment scenarios.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum NetworkMode {
28    /// Production mode with full anti-Sybil protection.
29    #[default]
30    Production,
31    /// Testnet mode with relaxed diversity requirements.
32    /// Suitable for single-provider deployments (e.g., Digital Ocean).
33    Testnet,
34    /// Development mode with minimal restrictions.
35    /// Only use for local testing.
36    Development,
37}
38
39/// Testnet-specific configuration for relaxed IP diversity limits.
40///
41/// saorsa-core uses a simple 2-tier model: per-exact-IP and per-subnet
42/// (/24 IPv4, /64 IPv6) limits.  Testnet defaults are permissive so
43/// nodes co-located on a single provider (e.g. Digital Ocean) can join.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TestnetConfig {
46    /// Maximum nodes sharing an exact IP address.
47    /// Default: `usize::MAX` (effectively unlimited for testnet).
48    #[serde(default = "default_testnet_max_per_ip")]
49    pub max_per_ip: Option<usize>,
50
51    /// Maximum nodes in the same /24 (IPv4) or /64 (IPv6) subnet.
52    /// Default: `usize::MAX` (effectively unlimited for testnet).
53    #[serde(default = "default_testnet_max_per_subnet")]
54    pub max_per_subnet: Option<usize>,
55}
56
57impl Default for TestnetConfig {
58    fn default() -> Self {
59        Self {
60            max_per_ip: default_testnet_max_per_ip(),
61            max_per_subnet: default_testnet_max_per_subnet(),
62        }
63    }
64}
65
66// These return `Option` because `serde(default = "...")` requires the function's
67// return type to match the field type (`Option<usize>`).
68#[allow(clippy::unnecessary_wraps)]
69const fn default_testnet_max_per_ip() -> Option<usize> {
70    Some(usize::MAX)
71}
72
73#[allow(clippy::unnecessary_wraps)]
74const fn default_testnet_max_per_subnet() -> Option<usize> {
75    Some(usize::MAX)
76}
77
78/// Node configuration.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct NodeConfig {
81    /// Root directory for node data.
82    #[serde(default = "default_root_dir")]
83    pub root_dir: PathBuf,
84
85    /// Listening port (0 for auto-select).
86    #[serde(default)]
87    pub port: u16,
88
89    /// Force IPv4-only mode.
90    ///
91    /// When true, the node binds only on IPv4 instead of dual-stack.
92    /// Use this on hosts without working IPv6 to avoid advertising
93    /// unreachable addresses to the DHT.
94    #[serde(default)]
95    pub ipv4_only: bool,
96
97    /// Bootstrap peer addresses.
98    #[serde(default)]
99    pub bootstrap: Vec<SocketAddr>,
100
101    /// Network mode (production, testnet, or development).
102    #[serde(default)]
103    pub network_mode: NetworkMode,
104
105    /// Testnet-specific configuration.
106    /// Only used when `network_mode` is `Testnet`.
107    #[serde(default)]
108    pub testnet: TestnetConfig,
109
110    /// Upgrade configuration.
111    #[serde(default)]
112    pub upgrade: UpgradeConfig,
113
114    /// Payment verification configuration.
115    #[serde(default)]
116    pub payment: PaymentConfig,
117
118    /// Bootstrap cache configuration for persistent peer storage.
119    #[serde(default)]
120    pub bootstrap_cache: BootstrapCacheConfig,
121
122    /// Storage configuration for chunk persistence.
123    #[serde(default)]
124    pub storage: StorageConfig,
125
126    /// Directory for persisting the close group cache.
127    ///
128    /// When `None` (default), the node's `root_dir` is used — the cache
129    /// file lands alongside `node_identity.key`.
130    #[serde(default)]
131    pub close_group_cache_dir: Option<PathBuf>,
132
133    /// Maximum application-layer message size in bytes.
134    ///
135    /// Tunes the QUIC stream receive window and per-stream read buffer.
136    /// Default: [`MAX_WIRE_MESSAGE_SIZE`](crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE)
137    /// (5 MiB — sufficient for 4 MiB data chunks plus serialization
138    /// envelope overhead).
139    #[serde(default = "default_max_message_size")]
140    pub max_message_size: usize,
141
142    /// Log level.
143    #[serde(default = "default_log_level")]
144    pub log_level: String,
145}
146
147/// Auto-upgrade configuration.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct UpgradeConfig {
150    /// Release channel.
151    #[serde(default)]
152    pub channel: UpgradeChannel,
153
154    /// Check interval in hours.
155    #[serde(default = "default_check_interval")]
156    pub check_interval_hours: u64,
157
158    /// GitHub repository in "owner/repo" format for release monitoring.
159    #[serde(default = "default_github_repo")]
160    pub github_repo: String,
161
162    /// Staged rollout window in hours.
163    ///
164    /// When a new version is detected, each node waits a deterministic delay
165    /// based on its node ID before applying the upgrade. This prevents mass
166    /// restarts and ensures network stability during upgrades.
167    ///
168    /// Set to 0 to disable staged rollout (apply upgrades immediately).
169    #[serde(default = "default_staged_rollout_hours")]
170    pub staged_rollout_hours: u64,
171
172    /// Exit cleanly on upgrade instead of spawning a new process.
173    ///
174    /// When true, the node exits after applying an upgrade and relies on
175    /// an external service manager (systemd, launchd, Windows Service) to
176    /// restart it. When false (default), the node spawns the new binary
177    /// as a child process before exiting.
178    #[serde(default)]
179    pub stop_on_upgrade: bool,
180}
181
182/// EVM network for payment processing.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
184#[serde(rename_all = "kebab-case")]
185pub enum EvmNetworkConfig {
186    /// Arbitrum One mainnet.
187    #[default]
188    ArbitrumOne,
189    /// Arbitrum Sepolia testnet.
190    ArbitrumSepolia,
191}
192
193/// Payment verification configuration.
194///
195/// All new data requires EVM payment on Arbitrum — there is no way to
196/// disable payment verification. The cache stores previously verified
197/// payments to avoid redundant on-chain lookups.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct PaymentConfig {
200    /// Cache capacity for verified `XorNames`.
201    #[serde(default = "default_cache_capacity")]
202    pub cache_capacity: usize,
203
204    /// EVM wallet address for receiving payments (e.g., "0x...").
205    /// If not set, the node will not be able to receive payments.
206    #[serde(default)]
207    pub rewards_address: Option<String>,
208
209    /// EVM network for payment processing.
210    #[serde(default)]
211    pub evm_network: EvmNetworkConfig,
212
213    /// Metrics port for Prometheus scraping.
214    /// Set to 0 to disable metrics endpoint.
215    #[serde(default = "default_metrics_port")]
216    pub metrics_port: u16,
217}
218
219impl Default for PaymentConfig {
220    fn default() -> Self {
221        Self {
222            cache_capacity: default_cache_capacity(),
223            rewards_address: None,
224            evm_network: EvmNetworkConfig::default(),
225            metrics_port: default_metrics_port(),
226        }
227    }
228}
229
230const fn default_metrics_port() -> u16 {
231    9100
232}
233
234const fn default_cache_capacity() -> usize {
235    100_000
236}
237
238impl Default for NodeConfig {
239    fn default() -> Self {
240        Self {
241            root_dir: default_root_dir(),
242            port: 0,
243            ipv4_only: false,
244            bootstrap: Vec::new(),
245            network_mode: NetworkMode::default(),
246            testnet: TestnetConfig::default(),
247            upgrade: UpgradeConfig::default(),
248            payment: PaymentConfig::default(),
249            bootstrap_cache: BootstrapCacheConfig::default(),
250            storage: StorageConfig::default(),
251            close_group_cache_dir: None,
252            max_message_size: default_max_message_size(),
253            log_level: default_log_level(),
254        }
255    }
256}
257
258impl NodeConfig {
259    /// Create a testnet configuration preset.
260    ///
261    /// This is a convenience method for setting up a testnet node with
262    /// relaxed anti-Sybil protection, suitable for single-provider deployments.
263    /// Includes default bootstrap nodes for the Autonomi testnet.
264    #[must_use]
265    pub fn testnet() -> Self {
266        Self {
267            network_mode: NetworkMode::Testnet,
268            testnet: TestnetConfig::default(),
269            bootstrap: default_testnet_bootstrap(),
270            ..Self::default()
271        }
272    }
273
274    /// Create a development configuration preset.
275    ///
276    /// This has minimal restrictions and is only suitable for local testing.
277    #[must_use]
278    pub fn development() -> Self {
279        Self {
280            network_mode: NetworkMode::Development,
281            testnet: TestnetConfig {
282                max_per_ip: Some(usize::MAX),
283                max_per_subnet: Some(usize::MAX),
284            },
285            ..Self::default()
286        }
287    }
288
289    /// Check if this configuration is using relaxed security settings.
290    #[must_use]
291    pub fn is_relaxed(&self) -> bool {
292        !matches!(self.network_mode, NetworkMode::Production)
293    }
294
295    /// Load configuration from a TOML file.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error if the file cannot be read or parsed.
300    pub fn from_file(path: &std::path::Path) -> crate::Result<Self> {
301        let content = std::fs::read_to_string(path)?;
302        toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))
303    }
304
305    /// Save configuration to a TOML file.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the file cannot be written.
310    pub fn to_file(&self, path: &std::path::Path) -> crate::Result<()> {
311        let content =
312            toml::to_string_pretty(self).map_err(|e| crate::Error::Config(e.to_string()))?;
313        std::fs::write(path, content)?;
314        Ok(())
315    }
316}
317
318impl Default for UpgradeConfig {
319    fn default() -> Self {
320        Self {
321            channel: UpgradeChannel::default(),
322            check_interval_hours: default_check_interval(),
323            github_repo: default_github_repo(),
324            staged_rollout_hours: default_staged_rollout_hours(),
325            stop_on_upgrade: false,
326        }
327    }
328}
329
330fn default_github_repo() -> String {
331    "WithAutonomi/ant-node".to_string()
332}
333
334/// Default base directory for node data (platform data dir for "ant").
335#[must_use]
336pub fn default_root_dir() -> PathBuf {
337    directories::ProjectDirs::from("", "", "ant").map_or_else(
338        || PathBuf::from(".ant"),
339        |dirs| dirs.data_dir().to_path_buf(),
340    )
341}
342
343/// Default directory containing per-node data subdirectories.
344///
345/// Each node gets `{default_root_dir}/nodes/{peer_id}/` where `peer_id` is the
346/// full 64-character hex-encoded node ID.
347#[must_use]
348pub fn default_nodes_dir() -> PathBuf {
349    default_root_dir().join(NODES_SUBDIR)
350}
351
352fn default_max_message_size() -> usize {
353    crate::ant_protocol::MAX_WIRE_MESSAGE_SIZE
354}
355
356fn default_log_level() -> String {
357    "info".to_string()
358}
359
360const fn default_check_interval() -> u64 {
361    1 // 1 hour
362}
363
364const fn default_staged_rollout_hours() -> u64 {
365    24 // 24 hour window for staged rollout
366}
367
368// ============================================================================
369// Bootstrap Cache Configuration
370// ============================================================================
371
372/// Bootstrap cache configuration for persistent peer storage.
373///
374/// The bootstrap cache stores discovered peers across node restarts,
375/// ranking them by quality metrics (success rate, latency, recency).
376/// This reduces dependency on hardcoded bootstrap nodes and enables
377/// faster network reconnection after restarts.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct BootstrapCacheConfig {
380    /// Enable persistent bootstrap cache.
381    /// Default: true
382    #[serde(default = "default_bootstrap_cache_enabled")]
383    pub enabled: bool,
384
385    /// Directory for cache files.
386    /// Default: `{root_dir}/bootstrap_cache/`
387    #[serde(default)]
388    pub cache_dir: Option<PathBuf>,
389
390    /// Maximum contacts to store in the cache.
391    /// Default: 10,000
392    #[serde(default = "default_bootstrap_max_contacts")]
393    pub max_contacts: usize,
394
395    /// Stale contact threshold in days.
396    /// Contacts older than this are removed during cleanup.
397    /// Default: 7 days
398    #[serde(default = "default_bootstrap_stale_days")]
399    pub stale_threshold_days: u64,
400}
401
402impl Default for BootstrapCacheConfig {
403    fn default() -> Self {
404        Self {
405            enabled: default_bootstrap_cache_enabled(),
406            cache_dir: None,
407            max_contacts: default_bootstrap_max_contacts(),
408            stale_threshold_days: default_bootstrap_stale_days(),
409        }
410    }
411}
412
413const fn default_bootstrap_cache_enabled() -> bool {
414    true
415}
416
417const fn default_bootstrap_max_contacts() -> usize {
418    10_000
419}
420
421const fn default_bootstrap_stale_days() -> u64 {
422    7
423}
424
425// ============================================================================
426// Storage Configuration
427// ============================================================================
428
429/// Storage configuration for chunk persistence.
430///
431/// Controls how chunks are stored, including:
432/// - Whether storage is enabled
433/// - Maximum chunks to store (for capacity management)
434/// - Content verification on read
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct StorageConfig {
437    /// Enable chunk storage.
438    /// Default: true
439    #[serde(default = "default_storage_enabled")]
440    pub enabled: bool,
441
442    /// Maximum number of chunks to store (0 = unlimited).
443    /// Default: 0 (unlimited)
444    #[serde(default)]
445    pub max_chunks: usize,
446
447    /// Verify content hash matches address on read.
448    /// Default: true
449    #[serde(default = "default_storage_verify_on_read")]
450    pub verify_on_read: bool,
451
452    /// Maximum LMDB database size in GiB (0 = use default of 32 GiB).
453    /// On Unix the mmap is a lazy reservation and costs nothing until pages
454    /// are faulted in.
455    #[serde(default)]
456    pub db_size_gb: usize,
457}
458
459impl Default for StorageConfig {
460    fn default() -> Self {
461        Self {
462            enabled: default_storage_enabled(),
463            max_chunks: 0,
464            verify_on_read: default_storage_verify_on_read(),
465            db_size_gb: 0,
466        }
467    }
468}
469
470const fn default_storage_enabled() -> bool {
471    true
472}
473
474const fn default_storage_verify_on_read() -> bool {
475    true
476}
477
478// ============================================================================
479// Bootstrap Peers Configuration (shipped config file)
480// ============================================================================
481
482/// The filename for the bootstrap peers configuration file.
483pub const BOOTSTRAP_PEERS_FILENAME: &str = "bootstrap_peers.toml";
484
485/// Environment variable that overrides the bootstrap peers file search path.
486pub const BOOTSTRAP_PEERS_ENV: &str = "ANT_BOOTSTRAP_PEERS_PATH";
487
488/// Bootstrap peers loaded from a shipped configuration file.
489///
490/// This file provides initial peers for first-time network joins.
491/// It is separate from the bootstrap *cache* (which stores quality-ranked
492/// peers discovered at runtime).
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct BootstrapPeersConfig {
495    /// The bootstrap peer socket addresses.
496    #[serde(default)]
497    pub peers: Vec<SocketAddr>,
498}
499
500/// The source from which bootstrap peers were resolved.
501#[derive(Debug, Clone, PartialEq, Eq)]
502pub enum BootstrapSource {
503    /// Provided via `--bootstrap` CLI argument or `ANT_BOOTSTRAP` env var.
504    Cli,
505    /// Loaded from an explicit `--config` file.
506    ConfigFile,
507    /// Auto-discovered from a `bootstrap_peers.toml` file.
508    AutoDiscovered(PathBuf),
509    /// No bootstrap peers were found from any source.
510    None,
511}
512
513impl BootstrapPeersConfig {
514    /// Load bootstrap peers from a TOML file at the given path.
515    ///
516    /// # Errors
517    ///
518    /// Returns an error if the file cannot be read or contains invalid TOML.
519    pub fn from_file(path: &Path) -> crate::Result<Self> {
520        let content = std::fs::read_to_string(path)?;
521        toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))
522    }
523
524    /// Search well-known locations for a `bootstrap_peers.toml` file and load it.
525    ///
526    /// Search order (first match wins):
527    /// 1. `$ANT_BOOTSTRAP_PEERS_PATH` environment variable (path to file)
528    /// 2. Same directory as the running executable
529    /// 3. Platform config directory (`~/.config/ant/` on Linux,
530    ///    `~/Library/Application Support/ant/` on macOS,
531    ///    `%APPDATA%\ant\` on Windows)
532    /// 4. System config: `/etc/ant/` (Unix only)
533    ///
534    /// Returns `None` if no file is found in any location.
535    #[must_use]
536    pub fn discover() -> Option<(Self, PathBuf)> {
537        let candidates = Self::search_paths();
538        for path in candidates {
539            if path.is_file() {
540                match Self::from_file(&path) {
541                    Ok(config) if !config.peers.is_empty() => return Some((config, path)),
542                    Ok(_) => {}
543                    Err(err) => {
544                        eprintln!(
545                            "Warning: failed to load bootstrap peers from {}: {err}",
546                            path.display(),
547                        );
548                    }
549                }
550            }
551        }
552        None
553    }
554
555    /// Build the ordered list of candidate paths to search.
556    fn search_paths() -> Vec<PathBuf> {
557        let mut paths = Vec::new();
558
559        // 1. Environment variable override.
560        if let Ok(env_path) = std::env::var(BOOTSTRAP_PEERS_ENV) {
561            paths.push(PathBuf::from(env_path));
562        }
563
564        // 2. Next to the running executable.
565        if let Ok(exe) = std::env::current_exe() {
566            if let Some(exe_dir) = exe.parent() {
567                paths.push(exe_dir.join(BOOTSTRAP_PEERS_FILENAME));
568            }
569        }
570
571        // 3. Platform config directory.
572        if let Some(proj_dirs) = directories::ProjectDirs::from("", "", "ant") {
573            paths.push(proj_dirs.config_dir().join(BOOTSTRAP_PEERS_FILENAME));
574        }
575
576        // 4. System config (Unix only).
577        #[cfg(unix)]
578        {
579            paths.push(PathBuf::from("/etc/ant").join(BOOTSTRAP_PEERS_FILENAME));
580        }
581
582        paths
583    }
584}
585
586/// Default testnet bootstrap nodes.
587///
588/// These are well-known bootstrap nodes for the Autonomi testnet.
589/// - ant-bootstrap-1 (NYC): 165.22.4.178:12000
590/// - ant-bootstrap-2 (SFO): 164.92.111.156:12000
591fn default_testnet_bootstrap() -> Vec<SocketAddr> {
592    vec![
593        // ant-bootstrap-1 (Digital Ocean NYC1)
594        SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(165, 22, 4, 178), 12000)),
595        // ant-bootstrap-2 (Digital Ocean SFO3)
596        SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(164, 92, 111, 156), 12000)),
597    ]
598}
599
600#[cfg(test)]
601#[allow(clippy::unwrap_used, clippy::expect_used)]
602mod tests {
603    use super::*;
604    use serial_test::serial;
605
606    #[test]
607    fn test_default_config_has_cache_capacity() {
608        let config = PaymentConfig::default();
609        assert!(config.cache_capacity > 0, "Cache capacity must be positive");
610    }
611
612    #[test]
613    fn test_default_evm_network() {
614        use crate::payment::EvmVerifierConfig;
615        let _config = EvmVerifierConfig::default();
616        // EVM verification is always on — no enabled field
617    }
618
619    #[test]
620    fn test_bootstrap_peers_parse_valid_toml() {
621        let toml_str = r#"
622            peers = [
623                "127.0.0.1:10000",
624                "192.168.1.1:10001",
625            ]
626        "#;
627        let config: BootstrapPeersConfig =
628            toml::from_str(toml_str).expect("valid TOML should parse");
629        assert_eq!(config.peers.len(), 2);
630        assert_eq!(config.peers[0].port(), 10000);
631        assert_eq!(config.peers[1].port(), 10001);
632    }
633
634    #[test]
635    fn test_bootstrap_peers_parse_empty_peers() {
636        let toml_str = r"peers = []";
637        let config: BootstrapPeersConfig =
638            toml::from_str(toml_str).expect("empty peers should parse");
639        assert!(config.peers.is_empty());
640    }
641
642    #[test]
643    fn test_bootstrap_peers_parse_missing_peers_field() {
644        let toml_str = "";
645        let config: BootstrapPeersConfig =
646            toml::from_str(toml_str).expect("missing field should use default");
647        assert!(config.peers.is_empty());
648    }
649
650    #[test]
651    fn test_bootstrap_peers_from_file() {
652        let dir = tempfile::tempdir().expect("create temp dir");
653        let path = dir.path().join("bootstrap_peers.toml");
654        std::fs::write(&path, r#"peers = ["10.0.0.1:10000", "10.0.0.2:10000"]"#)
655            .expect("write file");
656
657        let config = BootstrapPeersConfig::from_file(&path).expect("load from file");
658        assert_eq!(config.peers.len(), 2);
659    }
660
661    #[test]
662    fn test_bootstrap_peers_from_file_invalid_toml() {
663        let dir = tempfile::tempdir().expect("create temp dir");
664        let path = dir.path().join("bootstrap_peers.toml");
665        std::fs::write(&path, "not valid toml [[[").expect("write file");
666
667        assert!(BootstrapPeersConfig::from_file(&path).is_err());
668    }
669
670    /// Env-var-based discovery tests must run serially because they mutate
671    /// a shared process-wide environment variable.
672    #[test]
673    #[serial]
674    fn test_bootstrap_peers_discover_env_var() {
675        // Sub-test 1: valid file with peers is discovered.
676        {
677            let dir = tempfile::tempdir().expect("create temp dir");
678            let path = dir.path().join("bootstrap_peers.toml");
679            std::fs::write(&path, r#"peers = ["10.0.0.1:10000"]"#).expect("write file");
680
681            std::env::set_var(BOOTSTRAP_PEERS_ENV, &path);
682            let result = BootstrapPeersConfig::discover();
683            std::env::remove_var(BOOTSTRAP_PEERS_ENV);
684
685            let (config, discovered_path) = result.expect("should discover from env var");
686            assert_eq!(config.peers.len(), 1);
687            assert_eq!(discovered_path, path);
688        }
689
690        // Sub-test 2: file with empty peers list is skipped.
691        {
692            let dir = tempfile::tempdir().expect("create temp dir");
693            let path = dir.path().join("bootstrap_peers.toml");
694            std::fs::write(&path, r"peers = []").expect("write file");
695
696            std::env::set_var(BOOTSTRAP_PEERS_ENV, &path);
697            let result = BootstrapPeersConfig::discover();
698            std::env::remove_var(BOOTSTRAP_PEERS_ENV);
699
700            assert!(result.is_none(), "empty peers file should be skipped");
701        }
702    }
703
704    #[test]
705    fn test_bootstrap_peers_search_paths_contains_exe_dir() {
706        let paths = BootstrapPeersConfig::search_paths();
707        // At minimum, the exe-dir candidate should be present.
708        assert!(
709            paths
710                .iter()
711                .any(|p| p.file_name().is_some_and(|f| f == BOOTSTRAP_PEERS_FILENAME)),
712            "search paths should include a candidate with the bootstrap peers filename"
713        );
714    }
715}