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