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