Skip to main content

fips_core/config/
mod.rs

1//! FIPS Configuration System
2//!
3//! Loads configuration from YAML files with a cascading priority system:
4//! 1. `./fips.yaml` (current directory - highest priority)
5//! 2. `~/.config/fips/fips.yaml` (user config directory)
6//! 3. `/etc/fips/fips.yaml` (system - lowest priority)
7//!
8//! Values from higher priority files override those from lower priority files.
9//!
10//! # YAML Structure
11//!
12//! The YAML structure mirrors the sysctl-style paths in the architecture docs.
13//! For example, `node.identity.nsec` in the docs corresponds to:
14//!
15//! ```yaml
16//! node:
17//!   identity:
18//!     nsec: "nsec1..."
19//! ```
20
21#[cfg(target_os = "linux")]
22mod gateway;
23mod node;
24mod peer;
25mod transport;
26
27use crate::upper::config::{DnsConfig, TunConfig};
28use crate::{Identity, IdentityError};
29use serde::{Deserialize, Serialize};
30use std::path::{Path, PathBuf};
31use thiserror::Error;
32
33#[cfg(target_os = "linux")]
34pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
35pub use node::{
36    BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
37    NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
38    RetryConfig, RoutingConfig, RoutingMode, SessionConfig, SessionMmpConfig, TreeConfig,
39};
40pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
41#[cfg(feature = "sim-transport")]
42pub use transport::SimTransportConfig;
43pub use transport::{
44    BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
45    TransportsConfig, UdpConfig, WebRtcConfig,
46};
47
48/// Default config filename.
49const CONFIG_FILENAME: &str = "fips.yaml";
50
51/// Default key filename, placed alongside the config file.
52const KEY_FILENAME: &str = "fips.key";
53
54/// Default public key filename, placed alongside the key file.
55const PUB_FILENAME: &str = "fips.pub";
56
57/// Returns true if the textual `host:port` form refers to a loopback host.
58/// Recognizes IPv4 `127.x.x.x`, IPv6 `::1` (with or without brackets), and
59/// the literal string `localhost`. Hostnames are conservatively assumed to
60/// be non-loopback. Used by `Config::validate()` to reject misconfigured
61/// loopback UDP binds combined with non-loopback peer addresses (see
62/// ISSUE-2026-0005).
63fn is_loopback_addr_str(addr: &str) -> bool {
64    // Bracketed IPv6: `[::1]:port`
65    if let Some(rest) = addr.strip_prefix('[')
66        && let Some(end) = rest.find(']')
67    {
68        let host = &rest[..end];
69        return host == "::1";
70    }
71    // Plain `host:port` — split on the rightmost ':'.
72    let host = match addr.rsplit_once(':') {
73        Some((h, _)) => h,
74        None => addr,
75    };
76    host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
77}
78
79/// Derive the key file path from a config file path.
80pub fn key_file_path(config_path: &Path) -> PathBuf {
81    config_path
82        .parent()
83        .unwrap_or(Path::new("."))
84        .join(KEY_FILENAME)
85}
86
87/// Derive the public key file path from a config file path.
88pub fn pub_file_path(config_path: &Path) -> PathBuf {
89    config_path
90        .parent()
91        .unwrap_or(Path::new("."))
92        .join(PUB_FILENAME)
93}
94
95/// Resolve a default Unix-socket path under the canonical order:
96/// `/run/fips/<filename>` -> `$XDG_RUNTIME_DIR/fips/<filename>` -> `/tmp/fips-<filename>`.
97///
98/// `/run/fips` is the packaged convention. The resolver selects it whenever
99/// the directory exists so daemon and client defaults stay aligned. The daemon
100/// bind path creates missing parent directories; packaged installs create
101/// `/run/fips` via tmpfiles before service start.
102#[cfg(unix)]
103pub(crate) fn resolve_default_socket(filename: &str) -> String {
104    if Path::new("/run/fips").is_dir() {
105        return format!("/run/fips/{filename}");
106    }
107
108    if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR")
109        && Path::new(&xdg).is_dir()
110    {
111        return format!("{xdg}/fips/{filename}");
112    }
113
114    format!("/tmp/fips-{filename}")
115}
116
117/// Default control socket path for fipsctl / fipstop.
118///
119/// On Unix, checks the system-wide path first (used when the daemon runs as
120/// a systemd service), then falls back to the user's XDG runtime directory.
121/// On Windows, returns the default TCP port ("21210").
122pub fn default_control_path() -> PathBuf {
123    #[cfg(unix)]
124    {
125        PathBuf::from(resolve_default_socket("control.sock"))
126    }
127    #[cfg(windows)]
128    {
129        PathBuf::from("21210")
130    }
131}
132
133/// Default gateway control socket path.
134///
135/// On Unix, follows the same pattern as the main control socket.
136/// On Windows, returns a placeholder TCP port ("21211").
137pub fn default_gateway_path() -> PathBuf {
138    #[cfg(unix)]
139    {
140        PathBuf::from(resolve_default_socket("gateway.sock"))
141    }
142    #[cfg(windows)]
143    {
144        PathBuf::from("21211")
145    }
146}
147
148/// Read a bare bech32 nsec from a key file.
149pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
150    let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
151        path: path.to_path_buf(),
152        source: e,
153    })?;
154    let nsec = contents.trim().to_string();
155    if nsec.is_empty() {
156        return Err(ConfigError::EmptyKeyFile {
157            path: path.to_path_buf(),
158        });
159    }
160    Ok(nsec)
161}
162
163/// Write a bare bech32 nsec to a key file with restricted permissions.
164///
165/// On Unix, the file is created with mode 0600 (owner read/write only).
166/// On Windows, the file inherits default ACLs from the parent directory.
167pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
168    use std::io::Write;
169
170    let mut opts = std::fs::OpenOptions::new();
171    opts.write(true).create(true).truncate(true);
172
173    #[cfg(unix)]
174    {
175        use std::os::unix::fs::OpenOptionsExt;
176        opts.mode(0o600);
177    }
178
179    let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
180        path: path.to_path_buf(),
181        source: e,
182    })?;
183
184    #[cfg(unix)]
185    {
186        use std::os::unix::fs::PermissionsExt;
187        file.set_permissions(std::fs::Permissions::from_mode(0o600))
188            .map_err(|e| ConfigError::WriteKeyFile {
189                path: path.to_path_buf(),
190                source: e,
191            })?;
192    }
193
194    file.write_all(nsec.as_bytes())
195        .map_err(|e| ConfigError::WriteKeyFile {
196            path: path.to_path_buf(),
197            source: e,
198        })?;
199    file.write_all(b"\n")
200        .map_err(|e| ConfigError::WriteKeyFile {
201            path: path.to_path_buf(),
202            source: e,
203        })?;
204    Ok(())
205}
206
207/// Write a bare bech32 npub to a public key file.
208///
209/// On Unix, the file is created with mode 0644 (owner read/write, others read).
210/// On Windows, the file inherits default ACLs from the parent directory.
211pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
212    use std::io::Write;
213
214    let mut opts = std::fs::OpenOptions::new();
215    opts.write(true).create(true).truncate(true);
216
217    #[cfg(unix)]
218    {
219        use std::os::unix::fs::OpenOptionsExt;
220        opts.mode(0o644);
221    }
222
223    let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
224        path: path.to_path_buf(),
225        source: e,
226    })?;
227
228    file.write_all(npub.as_bytes())
229        .map_err(|e| ConfigError::WriteKeyFile {
230            path: path.to_path_buf(),
231            source: e,
232        })?;
233    file.write_all(b"\n")
234        .map_err(|e| ConfigError::WriteKeyFile {
235            path: path.to_path_buf(),
236            source: e,
237        })?;
238    Ok(())
239}
240
241/// Resolve identity from config and key file.
242///
243/// Behavior depends on `node.identity.persistent`:
244///
245/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
246///   every start. Key files are written for operator visibility but overwritten
247///   on each restart.
248///
249/// - **`persistent: true`**: use three-tier resolution:
250///   1. Explicit nsec in config — highest priority
251///   2. Persistent key file (`fips.key`) — reused across restarts
252///   3. Generate new — creates keypair, writes `fips.key` and `fips.pub`
253///
254/// - **`nsec` set explicitly**: always uses that, regardless of `persistent`.
255///
256/// Returns the nsec string (bech32 or hex) to be used for identity creation.
257pub fn resolve_identity(
258    config: &Config,
259    loaded_paths: &[PathBuf],
260) -> Result<ResolvedIdentity, ConfigError> {
261    use crate::encode_nsec;
262
263    // Explicit nsec in config always wins
264    if let Some(nsec) = &config.node.identity.nsec {
265        return Ok(ResolvedIdentity {
266            nsec: nsec.clone(),
267            source: IdentitySource::Config,
268        });
269    }
270
271    // Determine key file directory from loaded config paths
272    let config_ref = if let Some(path) = loaded_paths.last() {
273        path.clone()
274    } else {
275        Config::search_paths()
276            .first()
277            .cloned()
278            .unwrap_or_else(|| PathBuf::from("./fips.yaml"))
279    };
280    let key_path = key_file_path(&config_ref);
281    let pub_path = pub_file_path(&config_ref);
282
283    if config.node.identity.persistent {
284        // Persistent mode: load existing key file or generate-and-persist
285        if key_path.exists() {
286            let nsec = read_key_file(&key_path)?;
287            let identity = Identity::from_secret_str(&nsec)?;
288            let _ = write_pub_file(&pub_path, &identity.npub());
289            return Ok(ResolvedIdentity {
290                nsec,
291                source: IdentitySource::KeyFile(key_path),
292            });
293        }
294
295        // No key file yet — generate and persist
296        let identity = Identity::generate();
297        let nsec = encode_nsec(&identity.keypair().secret_key());
298        let npub = identity.npub();
299
300        if let Some(parent) = key_path.parent() {
301            let _ = std::fs::create_dir_all(parent);
302        }
303
304        match write_key_file(&key_path, &nsec) {
305            Ok(()) => {
306                let _ = write_pub_file(&pub_path, &npub);
307                Ok(ResolvedIdentity {
308                    nsec,
309                    source: IdentitySource::Generated(key_path),
310                })
311            }
312            Err(_) => Ok(ResolvedIdentity {
313                nsec,
314                source: IdentitySource::Ephemeral,
315            }),
316        }
317    } else {
318        // Ephemeral mode (default): fresh keypair every start, write key files
319        // for operator visibility
320        let identity = Identity::generate();
321        let nsec = encode_nsec(&identity.keypair().secret_key());
322        let npub = identity.npub();
323
324        if let Some(parent) = key_path.parent() {
325            let _ = std::fs::create_dir_all(parent);
326        }
327
328        let _ = write_key_file(&key_path, &nsec);
329        let _ = write_pub_file(&pub_path, &npub);
330
331        Ok(ResolvedIdentity {
332            nsec,
333            source: IdentitySource::Ephemeral,
334        })
335    }
336}
337
338/// Result of identity resolution.
339pub struct ResolvedIdentity {
340    /// The nsec string (bech32 or hex) for creating an Identity.
341    pub nsec: String,
342    /// Where the identity came from.
343    pub source: IdentitySource,
344}
345
346/// Where a resolved identity originated.
347pub enum IdentitySource {
348    /// From explicit nsec in config file.
349    Config,
350    /// Loaded from a persistent key file.
351    KeyFile(PathBuf),
352    /// Generated and saved to a new key file.
353    Generated(PathBuf),
354    /// Generated but could not be persisted.
355    Ephemeral,
356}
357
358/// Errors that can occur during configuration loading.
359#[derive(Debug, Error)]
360pub enum ConfigError {
361    #[error("failed to read config file {path}: {source}")]
362    ReadFile {
363        path: PathBuf,
364        source: std::io::Error,
365    },
366
367    #[error("failed to parse config file {path}: {source}")]
368    ParseYaml {
369        path: PathBuf,
370        source: serde_yaml::Error,
371    },
372
373    #[error("key file is empty: {path}")]
374    EmptyKeyFile { path: PathBuf },
375
376    #[error("failed to write key file {path}: {source}")]
377    WriteKeyFile {
378        path: PathBuf,
379        source: std::io::Error,
380    },
381
382    #[error("identity error: {0}")]
383    Identity(#[from] IdentityError),
384
385    #[error("invalid configuration: {0}")]
386    Validation(String),
387}
388
389/// Identity configuration (`node.identity.*`).
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391pub struct IdentityConfig {
392    /// Secret key in nsec (bech32) or hex format (`node.identity.nsec`).
393    /// If not specified, a new keypair will be generated.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub nsec: Option<String>,
396
397    /// Whether to persist the identity across restarts (`node.identity.persistent`).
398    /// When false (default), a fresh ephemeral keypair is generated each start.
399    /// When true, the key file is reused across restarts.
400    #[serde(default)]
401    pub persistent: bool,
402}
403
404/// Root configuration structure.
405#[derive(Debug, Clone, Default, Serialize, Deserialize)]
406pub struct Config {
407    /// Node configuration (`node.*`).
408    #[serde(default)]
409    pub node: NodeConfig,
410
411    /// TUN interface configuration (`tun.*`).
412    #[serde(default)]
413    pub tun: TunConfig,
414
415    /// DNS responder configuration (`dns.*`).
416    #[serde(default)]
417    pub dns: DnsConfig,
418
419    /// Transport instances (`transports.*`).
420    #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
421    pub transports: TransportsConfig,
422
423    /// Static peers to connect to (`peers`).
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub peers: Vec<PeerConfig>,
426
427    /// Gateway configuration (`gateway`).
428    #[cfg(target_os = "linux")]
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub gateway: Option<GatewayConfig>,
431}
432
433impl Config {
434    /// Create a new empty configuration.
435    pub fn new() -> Self {
436        Self::default()
437    }
438
439    /// Load configuration from the standard search paths.
440    ///
441    /// Files are loaded in reverse priority order and merged:
442    /// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority)
443    /// 2. `~/.config/fips/fips.yaml` (user config)
444    /// 3. `./fips.yaml` (loaded last, highest priority)
445    ///
446    /// Returns a tuple of (config, paths_loaded) where paths_loaded contains
447    /// the paths that were successfully loaded.
448    pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
449        let search_paths = Self::search_paths();
450        Self::load_from_paths(&search_paths)
451    }
452
453    /// Load configuration from specific paths.
454    ///
455    /// Paths are processed in order, with later paths overriding earlier ones.
456    pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
457        let mut config = Config::default();
458        let mut loaded_paths = Vec::new();
459
460        for path in paths {
461            if path.exists() {
462                let file_config = Self::load_file(path)?;
463                config.merge(file_config);
464                loaded_paths.push(path.clone());
465            }
466        }
467
468        Ok((config, loaded_paths))
469    }
470
471    /// Load configuration from a single file.
472    pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
473        let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
474            path: path.to_path_buf(),
475            source: e,
476        })?;
477
478        serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
479            path: path.to_path_buf(),
480            source: e,
481        })
482    }
483
484    /// Get the standard search paths in priority order (lowest to highest).
485    pub fn search_paths() -> Vec<PathBuf> {
486        let mut paths = Vec::new();
487
488        // System config (lowest priority)
489        paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
490
491        // User config directory
492        if let Some(config_dir) = dirs::config_dir() {
493            paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
494        }
495
496        // Home directory (legacy location)
497        if let Some(home_dir) = dirs::home_dir() {
498            paths.push(home_dir.join(".fips.yaml"));
499        }
500
501        // Current directory (highest priority)
502        paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
503
504        paths
505    }
506
507    /// Merge another configuration into this one.
508    ///
509    /// Values from `other` override values in `self` when present.
510    pub fn merge(&mut self, other: Config) {
511        // Merge node.identity section
512        if other.node.identity.nsec.is_some() {
513            self.node.identity.nsec = other.node.identity.nsec;
514        }
515        if other.node.identity.persistent {
516            self.node.identity.persistent = true;
517        }
518        // Merge node.leaf_only
519        if other.node.leaf_only {
520            self.node.leaf_only = true;
521        }
522        // Merge tun section
523        if other.tun.enabled {
524            self.tun.enabled = true;
525        }
526        if other.tun.name.is_some() {
527            self.tun.name = other.tun.name;
528        }
529        if other.tun.mtu.is_some() {
530            self.tun.mtu = other.tun.mtu;
531        }
532        // Merge dns section — higher-priority config always wins for enabled
533        self.dns.enabled = other.dns.enabled;
534        if other.dns.bind_addr.is_some() {
535            self.dns.bind_addr = other.dns.bind_addr;
536        }
537        if other.dns.port.is_some() {
538            self.dns.port = other.dns.port;
539        }
540        if other.dns.ttl.is_some() {
541            self.dns.ttl = other.dns.ttl;
542        }
543        // Merge transports section
544        self.transports.merge(other.transports);
545        // Merge peers (replace if non-empty)
546        if !other.peers.is_empty() {
547            self.peers = other.peers;
548        }
549        // Merge gateway section — higher-priority config replaces entirely
550        #[cfg(target_os = "linux")]
551        if other.gateway.is_some() {
552            self.gateway = other.gateway;
553        }
554    }
555
556    /// Create an Identity from this configuration.
557    ///
558    /// If an nsec is configured, uses that to create the identity.
559    /// Otherwise, generates a new random identity.
560    pub fn create_identity(&self) -> Result<Identity, ConfigError> {
561        match &self.node.identity.nsec {
562            Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
563            None => Ok(Identity::generate()),
564        }
565    }
566
567    /// Check if an identity is configured (vs. will be generated).
568    pub fn has_identity(&self) -> bool {
569        self.node.identity.nsec.is_some()
570    }
571
572    /// Check if leaf-only mode is configured.
573    pub fn is_leaf_only(&self) -> bool {
574        self.node.leaf_only
575    }
576
577    /// Get the configured peers.
578    pub fn peers(&self) -> &[PeerConfig] {
579        &self.peers
580    }
581
582    /// Get peers that should auto-connect on startup.
583    pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
584        self.peers.iter().filter(|p| p.is_auto_connect())
585    }
586
587    /// Validate cross-field configuration invariants.
588    pub fn validate(&self) -> Result<(), ConfigError> {
589        let nostr = &self.node.discovery.nostr;
590
591        let any_transport_advertises_on_nostr = self
592            .transports
593            .udp
594            .iter()
595            .any(|(_, cfg)| cfg.advertise_on_nostr())
596            || self
597                .transports
598                .tcp
599                .iter()
600                .any(|(_, cfg)| cfg.advertise_on_nostr())
601            || self
602                .transports
603                .tor
604                .iter()
605                .any(|(_, cfg)| cfg.advertise_on_nostr())
606            || self
607                .transports
608                .webrtc
609                .iter()
610                .any(|(_, cfg)| cfg.advertise_on_nostr());
611
612        if any_transport_advertises_on_nostr && !nostr.enabled {
613            return Err(ConfigError::Validation(
614                "at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
615            ));
616        }
617
618        for (i, peer) in self.peers.iter().enumerate() {
619            if peer.addresses.is_empty() && !nostr.enabled {
620                return Err(ConfigError::Validation(format!(
621                    "peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
622                    peer.npub
623                )));
624            }
625        }
626
627        let has_nat_udp_advert = self
628            .transports
629            .udp
630            .iter()
631            .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
632
633        if nostr.enabled && has_nat_udp_advert {
634            if nostr.dm_relays.is_empty() {
635                return Err(ConfigError::Validation(
636                    "NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
637                ));
638            }
639            if nostr.stun_servers.is_empty() {
640                return Err(ConfigError::Validation(
641                    "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
642                ));
643            }
644        }
645
646        let has_webrtc_advert_without_relays = self.transports.webrtc.iter().any(|(_, cfg)| {
647            cfg.advertise_on_nostr() && cfg.signal_relays(&nostr.dm_relays).is_empty()
648        });
649
650        if nostr.enabled && has_webrtc_advert_without_relays {
651            return Err(ConfigError::Validation(
652                "WebRTC advert publishing requires `node.discovery.nostr.dm_relays` or `transports.webrtc.signal_relays` to be non-empty".to_string(),
653            ));
654        }
655
656        // Reject loopback UDP bind combined with non-loopback peer addresses.
657        // Linux pins the source IP to a loopback-bound socket, so packets
658        // sent from such a socket to external peers are dropped at the
659        // routing layer with no clear error in the daemon log. See
660        // ISSUE-2026-0005. Outbound-only mode is exempt because it
661        // overrides bind_addr to 0.0.0.0:0 (kernel-picked source).
662        for (name, cfg) in self.transports.udp.iter() {
663            if cfg.outbound_only() {
664                continue;
665            }
666            if is_loopback_addr_str(cfg.bind_addr()) {
667                let any_external_peer = self.peers.iter().any(|peer| {
668                    peer.addresses
669                        .iter()
670                        .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
671                });
672                if any_external_peer {
673                    let label = name.unwrap_or("(unnamed)");
674                    return Err(ConfigError::Validation(format!(
675                        "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
676                         fips cannot reach external peers from a loopback-bound socket. \
677                         Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
678                        cfg.bind_addr()
679                    )));
680                }
681            }
682        }
683
684        Ok(())
685    }
686
687    /// Serialize this configuration to YAML.
688    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
689        serde_yaml::to_string(self)
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use std::collections::HashMap;
697    use std::fs;
698    use tempfile::TempDir;
699
700    #[cfg(unix)]
701    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
702
703    #[test]
704    fn test_empty_config() {
705        let config = Config::new();
706        assert!(config.node.identity.nsec.is_none());
707        assert!(!config.has_identity());
708    }
709
710    #[test]
711    fn test_parse_yaml_with_nsec() {
712        let yaml = r#"
713node:
714  identity:
715    nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
716"#;
717        let config: Config = serde_yaml::from_str(yaml).unwrap();
718        assert!(config.node.identity.nsec.is_some());
719        assert!(config.has_identity());
720    }
721
722    #[test]
723    fn test_parse_yaml_with_hex() {
724        let yaml = r#"
725node:
726  identity:
727    nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
728"#;
729        let config: Config = serde_yaml::from_str(yaml).unwrap();
730        assert!(config.node.identity.nsec.is_some());
731
732        let identity = config.create_identity().unwrap();
733        assert!(!identity.npub().is_empty());
734    }
735
736    #[test]
737    fn test_parse_yaml_empty() {
738        let yaml = "";
739        let config: Config = serde_yaml::from_str(yaml).unwrap();
740        assert!(config.node.identity.nsec.is_none());
741    }
742
743    #[test]
744    fn test_parse_yaml_partial() {
745        let yaml = r#"
746node:
747  identity: {}
748"#;
749        let config: Config = serde_yaml::from_str(yaml).unwrap();
750        assert!(config.node.identity.nsec.is_none());
751    }
752
753    #[test]
754    fn test_merge_configs() {
755        let mut base = Config::new();
756        base.node.identity.nsec = Some("base_nsec".to_string());
757
758        let mut override_config = Config::new();
759        override_config.node.identity.nsec = Some("override_nsec".to_string());
760
761        base.merge(override_config);
762        assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
763    }
764
765    #[test]
766    fn test_merge_preserves_base_when_override_empty() {
767        let mut base = Config::new();
768        base.node.identity.nsec = Some("base_nsec".to_string());
769
770        let override_config = Config::new();
771
772        base.merge(override_config);
773        assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
774    }
775
776    #[test]
777    fn test_create_identity_from_nsec() {
778        let mut config = Config::new();
779        config.node.identity.nsec =
780            Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
781
782        let identity = config.create_identity().unwrap();
783        assert!(!identity.npub().is_empty());
784    }
785
786    #[test]
787    fn test_create_identity_generates_new() {
788        let config = Config::new();
789        let identity = config.create_identity().unwrap();
790        assert!(!identity.npub().is_empty());
791    }
792
793    #[test]
794    fn test_load_from_file() {
795        let temp_dir = TempDir::new().unwrap();
796        let config_path = temp_dir.path().join("fips.yaml");
797
798        let yaml = r#"
799node:
800  identity:
801    nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
802"#;
803        fs::write(&config_path, yaml).unwrap();
804
805        let config = Config::load_file(&config_path).unwrap();
806        assert!(config.node.identity.nsec.is_some());
807    }
808
809    #[test]
810    fn test_load_from_paths_merges() {
811        let temp_dir = TempDir::new().unwrap();
812
813        // Create two config files
814        let low_priority = temp_dir.path().join("low.yaml");
815        let high_priority = temp_dir.path().join("high.yaml");
816
817        fs::write(
818            &low_priority,
819            r#"
820node:
821  identity:
822    nsec: "low_priority_nsec"
823"#,
824        )
825        .unwrap();
826
827        fs::write(
828            &high_priority,
829            r#"
830node:
831  identity:
832    nsec: "high_priority_nsec"
833"#,
834        )
835        .unwrap();
836
837        let paths = vec![low_priority.clone(), high_priority.clone()];
838        let (config, loaded) = Config::load_from_paths(&paths).unwrap();
839
840        assert_eq!(loaded.len(), 2);
841        assert_eq!(
842            config.node.identity.nsec,
843            Some("high_priority_nsec".to_string())
844        );
845    }
846
847    #[test]
848    fn test_load_skips_missing_files() {
849        let temp_dir = TempDir::new().unwrap();
850        let existing = temp_dir.path().join("exists.yaml");
851        let missing = temp_dir.path().join("missing.yaml");
852
853        fs::write(
854            &existing,
855            r#"
856node:
857  identity:
858    nsec: "existing_nsec"
859"#,
860        )
861        .unwrap();
862
863        let paths = vec![missing, existing.clone()];
864        let (config, loaded) = Config::load_from_paths(&paths).unwrap();
865
866        assert_eq!(loaded.len(), 1);
867        assert_eq!(loaded[0], existing);
868        assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
869    }
870
871    #[test]
872    fn test_search_paths_includes_expected() {
873        let paths = Config::search_paths();
874
875        // Should include current directory
876        assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
877
878        // Should include /etc/fips on Unix
879        #[cfg(unix)]
880        assert!(
881            paths
882                .iter()
883                .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
884        );
885    }
886
887    #[test]
888    fn test_to_yaml() {
889        let mut config = Config::new();
890        config.node.identity.nsec = Some("test_nsec".to_string());
891
892        let yaml = config.to_yaml().unwrap();
893        assert!(yaml.contains("node:"));
894        assert!(yaml.contains("identity:"));
895        assert!(yaml.contains("nsec:"));
896        assert!(yaml.contains("test_nsec"));
897    }
898
899    #[test]
900    fn test_key_file_write_read_roundtrip() {
901        let temp_dir = TempDir::new().unwrap();
902        let key_path = temp_dir.path().join("fips.key");
903
904        let identity = crate::Identity::generate();
905        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
906
907        write_key_file(&key_path, &nsec).unwrap();
908
909        let loaded_nsec = read_key_file(&key_path).unwrap();
910        assert_eq!(loaded_nsec, nsec);
911
912        // Verify the loaded nsec produces the same identity
913        let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
914        assert_eq!(loaded_identity.npub(), identity.npub());
915    }
916
917    #[cfg(unix)]
918    #[test]
919    fn test_key_file_permissions() {
920        use std::os::unix::fs::MetadataExt;
921
922        let temp_dir = TempDir::new().unwrap();
923        let key_path = temp_dir.path().join("fips.key");
924
925        write_key_file(&key_path, "nsec1test").unwrap();
926
927        let metadata = fs::metadata(&key_path).unwrap();
928        assert_eq!(metadata.mode() & 0o777, 0o600);
929    }
930
931    #[cfg(unix)]
932    #[test]
933    fn test_key_file_permissions_are_tightened_on_overwrite() {
934        use std::os::unix::fs::{MetadataExt, PermissionsExt};
935
936        let temp_dir = TempDir::new().unwrap();
937        let key_path = temp_dir.path().join("fips.key");
938        fs::write(&key_path, "old\n").unwrap();
939        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
940
941        write_key_file(&key_path, "nsec1test").unwrap();
942
943        let metadata = fs::metadata(&key_path).unwrap();
944        assert_eq!(metadata.mode() & 0o777, 0o600);
945        assert_eq!(read_key_file(&key_path).unwrap(), "nsec1test");
946    }
947
948    #[cfg(unix)]
949    #[test]
950    fn test_pub_file_permissions() {
951        use std::os::unix::fs::MetadataExt;
952
953        let temp_dir = TempDir::new().unwrap();
954        let pub_path = temp_dir.path().join("fips.pub");
955
956        write_pub_file(&pub_path, "npub1test").unwrap();
957
958        let metadata = fs::metadata(&pub_path).unwrap();
959        assert_eq!(metadata.mode() & 0o777, 0o644);
960    }
961
962    #[test]
963    fn test_key_file_empty_error() {
964        let temp_dir = TempDir::new().unwrap();
965        let key_path = temp_dir.path().join("fips.key");
966
967        fs::write(&key_path, "").unwrap();
968
969        let result = read_key_file(&key_path);
970        assert!(result.is_err());
971        assert!(result.unwrap_err().to_string().contains("empty"));
972    }
973
974    #[test]
975    fn test_key_file_whitespace_trimmed() {
976        let temp_dir = TempDir::new().unwrap();
977        let key_path = temp_dir.path().join("fips.key");
978
979        fs::write(&key_path, "  nsec1test  \n").unwrap();
980
981        let nsec = read_key_file(&key_path).unwrap();
982        assert_eq!(nsec, "nsec1test");
983    }
984
985    #[test]
986    fn test_key_file_path_derivation() {
987        let config_path = PathBuf::from("/etc/fips/fips.yaml");
988        assert_eq!(
989            key_file_path(&config_path),
990            PathBuf::from("/etc/fips/fips.key")
991        );
992        assert_eq!(
993            pub_file_path(&config_path),
994            PathBuf::from("/etc/fips/fips.pub")
995        );
996    }
997
998    #[cfg(windows)]
999    #[test]
1000    fn test_key_file_write_read_roundtrip_windows() {
1001        let temp_dir = TempDir::new().unwrap();
1002        let key_path = temp_dir.path().join("fips.key");
1003
1004        let identity = crate::Identity::generate();
1005        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1006
1007        write_key_file(&key_path, &nsec).unwrap();
1008
1009        // Verify file was created and can be read back
1010        let loaded_nsec = read_key_file(&key_path).unwrap();
1011        assert_eq!(loaded_nsec, nsec);
1012
1013        // Verify the loaded nsec produces the same identity
1014        let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
1015        assert_eq!(loaded_identity.npub(), identity.npub());
1016    }
1017
1018    #[test]
1019    fn test_resolve_identity_from_config() {
1020        let mut config = Config::new();
1021        config.node.identity.nsec =
1022            Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
1023
1024        let resolved = resolve_identity(&config, &[]).unwrap();
1025        assert!(matches!(resolved.source, IdentitySource::Config));
1026    }
1027
1028    #[test]
1029    fn test_resolve_identity_ephemeral_by_default() {
1030        let temp_dir = TempDir::new().unwrap();
1031        let config_path = temp_dir.path().join("fips.yaml");
1032
1033        fs::write(&config_path, "node:\n  identity: {}\n").unwrap();
1034
1035        let config = Config::load_file(&config_path).unwrap();
1036        assert!(!config.node.identity.persistent);
1037
1038        let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1039        assert!(matches!(resolved.source, IdentitySource::Ephemeral));
1040
1041        // Key files should still be written for operator visibility
1042        let key_path = temp_dir.path().join("fips.key");
1043        let pub_path = temp_dir.path().join("fips.pub");
1044        assert!(key_path.exists());
1045        assert!(pub_path.exists());
1046    }
1047
1048    #[test]
1049    fn test_resolve_identity_ephemeral_changes_each_call() {
1050        let temp_dir = TempDir::new().unwrap();
1051        let config_path = temp_dir.path().join("fips.yaml");
1052
1053        fs::write(&config_path, "node:\n  identity: {}\n").unwrap();
1054
1055        let config = Config::load_file(&config_path).unwrap();
1056        let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1057        let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1058
1059        // Each call generates a different key
1060        assert_ne!(first.nsec, second.nsec);
1061    }
1062
1063    #[test]
1064    fn test_resolve_identity_persistent_from_key_file() {
1065        let temp_dir = TempDir::new().unwrap();
1066        let config_path = temp_dir.path().join("fips.yaml");
1067        let key_path = temp_dir.path().join("fips.key");
1068
1069        fs::write(&config_path, "node:\n  identity:\n    persistent: true\n").unwrap();
1070
1071        // Write a key file
1072        let identity = crate::Identity::generate();
1073        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1074        write_key_file(&key_path, &nsec).unwrap();
1075
1076        let config = Config::load_file(&config_path).unwrap();
1077        assert!(config.node.identity.persistent);
1078
1079        let resolved = resolve_identity(&config, &[config_path]).unwrap();
1080        assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
1081        assert_eq!(resolved.nsec, nsec);
1082    }
1083
1084    #[test]
1085    fn test_resolve_identity_persistent_generates_and_persists() {
1086        let temp_dir = TempDir::new().unwrap();
1087        let config_path = temp_dir.path().join("fips.yaml");
1088
1089        fs::write(&config_path, "node:\n  identity:\n    persistent: true\n").unwrap();
1090
1091        let config = Config::load_file(&config_path).unwrap();
1092        let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1093
1094        assert!(matches!(resolved.source, IdentitySource::Generated(_)));
1095
1096        // Key file and pub file should now exist
1097        let key_path = temp_dir.path().join("fips.key");
1098        let pub_path = temp_dir.path().join("fips.pub");
1099        assert!(key_path.exists());
1100        assert!(pub_path.exists());
1101
1102        // Second resolve should load from key file (not generate new)
1103        let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1104        assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
1105        assert_eq!(resolved.nsec, resolved2.nsec);
1106    }
1107
1108    #[test]
1109    fn test_to_yaml_empty_nsec_omitted() {
1110        let config = Config::new();
1111        let yaml = config.to_yaml().unwrap();
1112
1113        // Empty nsec should not be serialized
1114        assert!(!yaml.contains("nsec:"));
1115    }
1116
1117    #[test]
1118    fn test_parse_transport_single_instance() {
1119        let yaml = r#"
1120transports:
1121  udp:
1122    bind_addr: "0.0.0.0:2121"
1123    mtu: 1400
1124"#;
1125        let config: Config = serde_yaml::from_str(yaml).unwrap();
1126
1127        assert_eq!(config.transports.udp.len(), 1);
1128        let instances: Vec<_> = config.transports.udp.iter().collect();
1129        assert_eq!(instances.len(), 1);
1130        assert_eq!(instances[0].0, None); // Single instance has no name
1131        assert_eq!(instances[0].1.bind_addr(), "0.0.0.0:2121");
1132        assert_eq!(instances[0].1.mtu(), 1400);
1133    }
1134
1135    #[test]
1136    fn test_parse_transport_named_instances() {
1137        let yaml = r#"
1138transports:
1139  udp:
1140    main:
1141      bind_addr: "0.0.0.0:2121"
1142    backup:
1143      bind_addr: "192.168.1.100:2122"
1144      mtu: 1280
1145"#;
1146        let config: Config = serde_yaml::from_str(yaml).unwrap();
1147
1148        assert_eq!(config.transports.udp.len(), 2);
1149
1150        let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
1151
1152        // Named instances have Some(name)
1153        assert!(instances.contains_key(&Some("main")));
1154        assert!(instances.contains_key(&Some("backup")));
1155        assert_eq!(instances[&Some("main")].bind_addr(), "0.0.0.0:2121");
1156        assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:2122");
1157        assert_eq!(instances[&Some("backup")].mtu(), 1280);
1158    }
1159
1160    #[test]
1161    fn test_parse_transport_empty() {
1162        let yaml = r#"
1163transports: {}
1164"#;
1165        let config: Config = serde_yaml::from_str(yaml).unwrap();
1166        assert!(config.transports.udp.is_empty());
1167        assert!(config.transports.is_empty());
1168    }
1169
1170    #[test]
1171    fn test_transport_instances_iter() {
1172        // Single instance - no name
1173        let single = TransportInstances::Single(UdpConfig {
1174            bind_addr: Some("0.0.0.0:2121".to_string()),
1175            mtu: None,
1176            ..Default::default()
1177        });
1178        let items: Vec<_> = single.iter().collect();
1179        assert_eq!(items.len(), 1);
1180        assert_eq!(items[0].0, None);
1181
1182        // Named instances - have names
1183        let mut map = HashMap::new();
1184        map.insert("a".to_string(), UdpConfig::default());
1185        map.insert("b".to_string(), UdpConfig::default());
1186        let named = TransportInstances::Named(map);
1187        let items: Vec<_> = named.iter().collect();
1188        assert_eq!(items.len(), 2);
1189        // All named instances should have Some(name)
1190        assert!(items.iter().all(|(name, _)| name.is_some()));
1191    }
1192
1193    #[test]
1194    fn test_parse_peer_config() {
1195        let yaml = r#"
1196peers:
1197  - npub: "npub1abc123"
1198    alias: "gateway"
1199    addresses:
1200      - transport: udp
1201        addr: "192.168.1.1:2121"
1202        priority: 1
1203      - transport: tor
1204        addr: "xyz.onion:2121"
1205        priority: 2
1206    connect_policy: auto_connect
1207"#;
1208        let config: Config = serde_yaml::from_str(yaml).unwrap();
1209
1210        assert_eq!(config.peers.len(), 1);
1211        let peer = &config.peers[0];
1212        assert_eq!(peer.npub, "npub1abc123");
1213        assert_eq!(peer.alias, Some("gateway".to_string()));
1214        assert_eq!(peer.addresses.len(), 2);
1215        assert!(peer.is_auto_connect());
1216
1217        // Check addresses are sorted by priority
1218        let sorted = peer.addresses_by_priority();
1219        assert_eq!(sorted[0].transport, "udp");
1220        assert_eq!(sorted[0].priority, 1);
1221        assert_eq!(sorted[1].transport, "tor");
1222        assert_eq!(sorted[1].priority, 2);
1223    }
1224
1225    #[test]
1226    fn test_parse_peer_minimal() {
1227        let yaml = r#"
1228peers:
1229  - npub: "npub1xyz"
1230    addresses:
1231      - transport: udp
1232        addr: "10.0.0.1:2121"
1233"#;
1234        let config: Config = serde_yaml::from_str(yaml).unwrap();
1235
1236        assert_eq!(config.peers.len(), 1);
1237        let peer = &config.peers[0];
1238        assert_eq!(peer.npub, "npub1xyz");
1239        assert!(peer.alias.is_none());
1240        // Default connect_policy is auto_connect
1241        assert!(peer.is_auto_connect());
1242        // Default priority is 100
1243        assert_eq!(peer.addresses[0].priority, 100);
1244    }
1245
1246    #[test]
1247    fn test_parse_multiple_peers() {
1248        let yaml = r#"
1249peers:
1250  - npub: "npub1peer1"
1251    addresses:
1252      - transport: udp
1253        addr: "10.0.0.1:2121"
1254  - npub: "npub1peer2"
1255    addresses:
1256      - transport: udp
1257        addr: "10.0.0.2:2121"
1258    connect_policy: on_demand
1259"#;
1260        let config: Config = serde_yaml::from_str(yaml).unwrap();
1261
1262        assert_eq!(config.peers.len(), 2);
1263        assert_eq!(config.auto_connect_peers().count(), 1);
1264    }
1265
1266    #[test]
1267    fn test_peer_config_builder() {
1268        let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:2121")
1269            .with_alias("test-peer")
1270            .with_address(PeerAddress::with_priority("tor", "xyz.onion:2121", 50));
1271
1272        assert_eq!(peer.npub, "npub1test");
1273        assert_eq!(peer.alias, Some("test-peer".to_string()));
1274        assert_eq!(peer.addresses.len(), 2);
1275        assert!(peer.is_auto_connect());
1276    }
1277
1278    #[test]
1279    fn test_parse_nostr_discovery_config() {
1280        let yaml = r#"
1281node:
1282  discovery:
1283    nostr:
1284      enabled: true
1285      advertise: false
1286      policy: configured_only
1287      open_discovery_max_pending: 12
1288      app: "fips.nat.test.v1"
1289      signal_ttl_secs: 45
1290      advert_relays:
1291        - "wss://relay-a.example"
1292      dm_relays:
1293        - "wss://relay-b.example"
1294      stun_servers:
1295        - "stun:stun.example.org:3478"
1296peers:
1297  - npub: "npub1peer"
1298    addresses:
1299      - transport: udp
1300        addr: "nat"
1301"#;
1302        let config: Config = serde_yaml::from_str(yaml).unwrap();
1303        assert!(config.node.discovery.nostr.enabled);
1304        assert!(!config.node.discovery.nostr.advertise);
1305        assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
1306        assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
1307        assert_eq!(
1308            config.node.discovery.nostr.policy,
1309            NostrDiscoveryPolicy::ConfiguredOnly
1310        );
1311        assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
1312        assert_eq!(
1313            config.node.discovery.nostr.advert_relays,
1314            vec!["wss://relay-a.example".to_string()]
1315        );
1316        assert_eq!(
1317            config.node.discovery.nostr.dm_relays,
1318            vec!["wss://relay-b.example".to_string()]
1319        );
1320        assert_eq!(
1321            config.node.discovery.nostr.stun_servers,
1322            vec!["stun:stun.example.org:3478".to_string()]
1323        );
1324        assert_eq!(
1325            config.peers[0].addresses[0].addr, "nat",
1326            "udp:nat address should parse without special-casing in YAML"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_validate_transport_advert_requires_nostr_enabled() {
1332        let mut config = Config::default();
1333        config.transports.udp = TransportInstances::Single(UdpConfig {
1334            advertise_on_nostr: Some(true),
1335            ..Default::default()
1336        });
1337        config.node.discovery.nostr.enabled = false;
1338
1339        let err = config.validate().expect_err("validation should fail");
1340        assert!(err.to_string().contains("advertise_on_nostr"));
1341
1342        config.transports.udp = TransportInstances::default();
1343        config.transports.webrtc = TransportInstances::Single(WebRtcConfig {
1344            advertise_on_nostr: Some(true),
1345            ..Default::default()
1346        });
1347
1348        let err = config.validate().expect_err("validation should fail");
1349        assert!(err.to_string().contains("advertise_on_nostr"));
1350    }
1351
1352    #[test]
1353    fn test_validate_empty_peer_addresses_require_nostr_enabled() {
1354        let mut config = Config {
1355            peers: vec![PeerConfig {
1356                npub: "npub1peer".to_string(),
1357                ..Default::default()
1358            }],
1359            ..Default::default()
1360        };
1361        config.node.discovery.nostr.enabled = false;
1362
1363        let err = config.validate().expect_err("validation should fail");
1364        assert!(err.to_string().contains("node.discovery.nostr"));
1365    }
1366
1367    #[test]
1368    fn test_validate_peer_addresses_optional_with_nostr_enabled() {
1369        // Empty addresses + Nostr discovery disabled -> error.
1370        let mut config = Config {
1371            peers: vec![PeerConfig {
1372                npub: "npub1peer".to_string(),
1373                ..Default::default()
1374            }],
1375            ..Default::default()
1376        };
1377        let err = config.validate().expect_err("validation should fail");
1378        assert!(err.to_string().contains("at least one address"));
1379
1380        // Empty addresses + Nostr discovery enabled -> ok.
1381        config.node.discovery.nostr.enabled = true;
1382        config
1383            .validate()
1384            .expect("Nostr discovery should allow empty addresses");
1385    }
1386
1387    #[test]
1388    fn test_validate_nat_udp_advert_requires_relays_and_stun() {
1389        let mut config = Config::default();
1390        config.node.discovery.nostr.enabled = true;
1391        config.node.discovery.nostr.dm_relays.clear();
1392        config.transports.udp = TransportInstances::Single(UdpConfig {
1393            advertise_on_nostr: Some(true),
1394            public: Some(false),
1395            ..Default::default()
1396        });
1397
1398        let err = config.validate().expect_err("validation should fail");
1399        assert!(err.to_string().contains("dm_relays"));
1400
1401        config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
1402        config.node.discovery.nostr.stun_servers.clear();
1403        let err = config.validate().expect_err("validation should fail");
1404        assert!(err.to_string().contains("stun_servers"));
1405    }
1406
1407    #[test]
1408    fn test_validate_webrtc_advert_requires_relays() {
1409        let mut config = Config::default();
1410        config.node.discovery.nostr.enabled = true;
1411        config.node.discovery.nostr.dm_relays.clear();
1412        config.transports.webrtc = TransportInstances::Single(WebRtcConfig {
1413            advertise_on_nostr: Some(true),
1414            ..Default::default()
1415        });
1416
1417        let err = config.validate().expect_err("validation should fail");
1418        assert!(err.to_string().contains("dm_relays"));
1419
1420        if let TransportInstances::Single(cfg) = &mut config.transports.webrtc {
1421            cfg.signal_relays = Some(vec!["wss://relay.example".to_string()]);
1422        }
1423        config
1424            .validate()
1425            .expect("WebRTC transport-specific relays should satisfy validation");
1426    }
1427
1428    #[test]
1429    fn test_is_loopback_addr_str() {
1430        assert!(is_loopback_addr_str("127.0.0.1:2121"));
1431        assert!(is_loopback_addr_str("127.0.0.5:9999"));
1432        assert!(is_loopback_addr_str("[::1]:2121"));
1433        assert!(is_loopback_addr_str("::1:2121"));
1434        assert!(is_loopback_addr_str("localhost:80"));
1435        assert!(!is_loopback_addr_str("0.0.0.0:2121"));
1436        assert!(!is_loopback_addr_str("192.168.1.1:2121"));
1437        assert!(!is_loopback_addr_str("[fd00::1]:2121"));
1438        assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121"));
1439        assert!(!is_loopback_addr_str("example.com:443"));
1440    }
1441
1442    #[cfg(unix)]
1443    #[test]
1444    fn test_resolve_default_socket_call_sites_agree() {
1445        let _guard = ENV_MUTEX.lock().unwrap();
1446
1447        let control_client = default_control_path().to_string_lossy().into_owned();
1448        let gateway_client = default_gateway_path().to_string_lossy().into_owned();
1449        let control_daemon = ControlConfig::default().socket_path;
1450
1451        assert_eq!(control_daemon, control_client);
1452
1453        let control_dir = Path::new(&control_client)
1454            .parent()
1455            .map(|p| p.to_string_lossy().into_owned())
1456            .unwrap_or_default();
1457        let gateway_dir = Path::new(&gateway_client)
1458            .parent()
1459            .map(|p| p.to_string_lossy().into_owned())
1460            .unwrap_or_default();
1461        assert_eq!(control_dir, gateway_dir);
1462    }
1463
1464    #[cfg(unix)]
1465    #[test]
1466    fn test_resolve_default_socket_xdg_when_no_run_fips() {
1467        let _guard = ENV_MUTEX.lock().unwrap();
1468
1469        let temp_dir = TempDir::new().unwrap();
1470        let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1471
1472        // SAFETY: serialized by ENV_MUTEX, so no other test in this module
1473        // observes the transient process environment.
1474        unsafe {
1475            std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path());
1476        }
1477
1478        let path = resolve_default_socket("control.sock");
1479
1480        // SAFETY: serialized by ENV_MUTEX.
1481        unsafe {
1482            match prev_xdg {
1483                Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1484                None => std::env::remove_var("XDG_RUNTIME_DIR"),
1485            }
1486        }
1487
1488        assert!(
1489            path.starts_with("/run/fips/")
1490                || path.starts_with(&format!("{}/fips/", temp_dir.path().display())),
1491            "expected /run/fips or XDG path, got: {path}"
1492        );
1493    }
1494
1495    #[cfg(unix)]
1496    #[test]
1497    fn test_resolve_default_socket_tmp_when_xdg_invalid() {
1498        let _guard = ENV_MUTEX.lock().unwrap();
1499
1500        let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1501        let bogus = "/nonexistent-xdg-runtime-dir-for-fips-test-zzz";
1502
1503        // SAFETY: serialized by ENV_MUTEX.
1504        unsafe {
1505            std::env::set_var("XDG_RUNTIME_DIR", bogus);
1506        }
1507
1508        let path = resolve_default_socket("gateway.sock");
1509
1510        // SAFETY: serialized by ENV_MUTEX.
1511        unsafe {
1512            match prev_xdg {
1513                Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1514                None => std::env::remove_var("XDG_RUNTIME_DIR"),
1515            }
1516        }
1517
1518        assert!(
1519            path.starts_with("/run/fips/") || path == "/tmp/fips-gateway.sock",
1520            "expected /run/fips or /tmp fallback, got: {path}"
1521        );
1522        assert!(
1523            !path.starts_with(bogus),
1524            "stale XDG_RUNTIME_DIR leaked into resolver: {path}"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_validate_loopback_bind_with_external_peer_rejected() {
1530        use crate::config::PeerAddress;
1531        let mut config = Config::default();
1532        config.transports.udp = TransportInstances::Single(UdpConfig {
1533            bind_addr: Some("127.0.0.1:2121".to_string()),
1534            ..Default::default()
1535        });
1536        config.peers = vec![PeerConfig {
1537            npub: "npub1peer".to_string(),
1538            addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1539            ..Default::default()
1540        }];
1541
1542        let err = config.validate().expect_err("validation should fail");
1543        let msg = err.to_string();
1544        assert!(msg.contains("loopback"), "got: {msg}");
1545        assert!(msg.contains("non-loopback"), "got: {msg}");
1546    }
1547
1548    #[test]
1549    fn test_validate_loopback_bind_with_loopback_peer_ok() {
1550        use crate::config::PeerAddress;
1551        let mut config = Config::default();
1552        config.transports.udp = TransportInstances::Single(UdpConfig {
1553            bind_addr: Some("127.0.0.1:2121".to_string()),
1554            ..Default::default()
1555        });
1556        config.peers = vec![PeerConfig {
1557            npub: "npub1peer".to_string(),
1558            addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")],
1559            ..Default::default()
1560        }];
1561
1562        config
1563            .validate()
1564            .expect("loopback peer with loopback bind should validate");
1565    }
1566
1567    #[test]
1568    fn test_validate_outbound_only_exempt_from_loopback_check() {
1569        use crate::config::PeerAddress;
1570        let mut config = Config::default();
1571        // outbound_only overrides bind_addr → 0.0.0.0:0; the loopback
1572        // check must skip this transport entirely.
1573        config.transports.udp = TransportInstances::Single(UdpConfig {
1574            bind_addr: Some("127.0.0.1:2121".to_string()),
1575            outbound_only: Some(true),
1576            ..Default::default()
1577        });
1578        config.peers = vec![PeerConfig {
1579            npub: "npub1peer".to_string(),
1580            addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1581            ..Default::default()
1582        }];
1583
1584        config
1585            .validate()
1586            .expect("outbound_only should be exempt from the loopback check");
1587    }
1588
1589    #[test]
1590    fn test_outbound_only_forces_ephemeral_bind() {
1591        let cfg = UdpConfig {
1592            bind_addr: Some("127.0.0.1:2121".to_string()),
1593            outbound_only: Some(true),
1594            ..Default::default()
1595        };
1596        assert_eq!(cfg.bind_addr(), "0.0.0.0:0");
1597        assert!(cfg.outbound_only());
1598    }
1599
1600    #[test]
1601    fn test_outbound_only_forces_advertise_off() {
1602        let cfg = UdpConfig {
1603            advertise_on_nostr: Some(true),
1604            outbound_only: Some(true),
1605            ..Default::default()
1606        };
1607        assert!(!cfg.advertise_on_nostr());
1608    }
1609
1610    #[test]
1611    fn test_udp_accept_connections_default_true() {
1612        let cfg = UdpConfig::default();
1613        assert!(cfg.accept_connections());
1614    }
1615}