Skip to main content

zainodlib/
config.rs

1//! Zaino config.
2
3use std::{
4    net::{IpAddr, SocketAddr},
5    path::PathBuf,
6};
7
8use serde::{Deserialize, Serialize};
9use tracing::info;
10#[cfg(any(
11    feature = "no_tls_use_unencrypted_traffic",
12    feature = "allow_unencrypted_public_json_rpc_bind"
13))]
14use tracing::warn;
15
16use crate::error::IndexerError;
17use zaino_common::{
18    try_resolve_address, AddressResolution, Network, ServiceConfig, StorageConfig, ValidatorConfig,
19};
20use zaino_serve::server::config::{GrpcServerConfig, JsonRpcServerConfig};
21#[allow(deprecated)]
22use zaino_state::{
23    BackendType, CommonBackendConfig, DonationAddress, FetchServiceConfig, StateServiceConfig,
24};
25
26/// Header for generated configuration files.
27pub const GENERATED_CONFIG_HEADER: &str = r#"# Zaino Configuration
28#
29# Generated with `zainod generate-config`
30#
31# Configuration sources are layered (highest priority first):
32#   1. Environment variables (prefix: ZAINO_)
33#   2. TOML configuration file
34#   3. Built-in defaults
35#
36# For detailed documentation, see:
37#   https://github.com/zingolabs/zaino
38
39"#;
40
41/// Generate default configuration file content.
42///
43/// Returns the full config file content including header and TOML-serialized defaults.
44pub fn generate_default_config() -> Result<String, IndexerError> {
45    let config = ZainodConfig::default();
46
47    let toml_content = toml::to_string_pretty(&config)
48        .map_err(|e| IndexerError::ConfigError(format!("Failed to serialize config: {}", e)))?;
49
50    Ok(format!("{}{}", GENERATED_CONFIG_HEADER, toml_content))
51}
52
53/// Sensitive key suffixes that should not be set via environment variables.
54const SENSITIVE_KEY_SUFFIXES: [&str; 5] = ["password", "secret", "token", "cookie", "private_key"];
55
56/// Checks if a key is sensitive and should not be set via environment variables.
57fn is_sensitive_leaf_key(leaf_key: &str) -> bool {
58    let key = leaf_key.to_ascii_lowercase();
59    SENSITIVE_KEY_SUFFIXES
60        .iter()
61        .any(|suffix| key.ends_with(suffix))
62}
63
64/// Zaino daemon configuration.
65///
66/// Field order matters for TOML serialization: simple values must come before tables.
67#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(deny_unknown_fields, default)]
69pub struct ZainodConfig {
70    // Simple values first (TOML requirement)
71    /// Backend type for fetching blockchain data.
72    pub backend: BackendType,
73    /// Path to Zebra's state database.
74    ///
75    /// Required when using the `state` backend.
76    pub zebra_db_path: PathBuf,
77    /// Network to connect to (Mainnet, Testnet, or Regtest).
78    pub network: Network,
79    /// Prometheus metrics endpoint listen address.
80    ///
81    /// Set to enable the `/metrics` scrape endpoint. Disabled when `None`.
82    /// Requires the `prometheus` feature; ignored without it.
83    pub metrics_endpoint: Option<SocketAddr>,
84
85    // Table sections
86    /// JSON-RPC server settings. Set to enable Zaino's JSON-RPC interface.
87    pub json_server_settings: Option<JsonRpcServerConfig>,
88    /// gRPC server settings (listen address, TLS configuration).
89    pub grpc_settings: GrpcServerConfig,
90    /// Validator connection settings.
91    pub validator_settings: ValidatorConfig,
92    /// Service-level settings (timeout, channel size).
93    pub service: ServiceConfig,
94    /// Storage settings (cache and database).
95    pub storage: StorageConfig,
96    /// Zcash donation UA address
97    pub donation_address: Option<DonationAddress>,
98}
99
100impl ZainodConfig {
101    /// Performs checks on config data.
102    pub(crate) fn check_config(&self) -> Result<(), IndexerError> {
103        // Check TLS settings.
104        if let Some(ref tls) = self.grpc_settings.tls {
105            if !std::path::Path::new(&tls.cert_path).exists() {
106                return Err(IndexerError::ConfigError(format!(
107                    "TLS is enabled, but certificate path {:?} does not exist.",
108                    tls.cert_path
109                )));
110            }
111
112            if !std::path::Path::new(&tls.key_path).exists() {
113                return Err(IndexerError::ConfigError(format!(
114                    "TLS is enabled, but key path {:?} does not exist.",
115                    tls.key_path
116                )));
117            }
118        }
119
120        // Check validator cookie authentication settings
121        if let Some(ref cookie_path) = self.validator_settings.validator_cookie_path {
122            if !std::path::Path::new(cookie_path).exists() {
123                return Err(IndexerError::ConfigError(format!(
124                    "Validator cookie authentication is enabled, but cookie path '{:?}' does not exist.",
125                    cookie_path
126                )));
127            }
128        }
129
130        #[cfg(not(feature = "no_tls_use_unencrypted_traffic"))]
131        let grpc_addr =
132            fetch_socket_addr_from_hostname(&self.grpc_settings.listen_address.to_string())?;
133
134        // Validate the validator address using the richer result type that distinguishes
135        // between format errors (always fail) and DNS lookup failures (can defer for Docker).
136        let validator_addr_result =
137            try_resolve_address(&self.validator_settings.validator_jsonrpc_listen_address);
138
139        // Validator address validation:
140        // - Resolved IPs: must be private (RFC1918/ULA)
141        // - Hostnames: validated at connection time (supports Docker/K8s service discovery)
142        // - Cookie auth: determined by validator_cookie_path config, not enforced by address type
143        match validator_addr_result {
144            AddressResolution::Resolved(validator_addr) => {
145                if !is_private_listen_addr(&validator_addr) {
146                    return Err(IndexerError::ConfigError(
147                        "Zaino may only connect to Zebra with private IP addresses.".to_string(),
148                    ));
149                }
150            }
151            AddressResolution::UnresolvedHostname { ref address, .. } => {
152                info!(
153                    %address,
154                    "validator address cannot be resolved at config time"
155                );
156            }
157            AddressResolution::InvalidFormat { address, reason } => {
158                // Invalid address format - always fail immediately.
159                return Err(IndexerError::ConfigError(format!(
160                    "Invalid validator address '{}': {}",
161                    address, reason
162                )));
163            }
164        }
165
166        #[cfg(not(feature = "no_tls_use_unencrypted_traffic"))]
167        {
168            // Ensure TLS is used when connecting to external addresses.
169            if !is_private_listen_addr(&grpc_addr) && self.grpc_settings.tls.is_none() {
170                return Err(IndexerError::ConfigError(
171                    "TLS required when connecting to external addresses.".to_string(),
172                ));
173            }
174        }
175
176        #[cfg(feature = "no_tls_use_unencrypted_traffic")]
177        {
178            warn!(
179                "Zaino built using no_tls_use_unencrypted_traffic feature, proceed with caution."
180            );
181        }
182
183        // The JSON-RPC interface is unencrypted and intended for loopback / trusted
184        // private networks only. Reject public bind addresses unless explicitly unlocked.
185        #[cfg(not(feature = "allow_unencrypted_public_json_rpc_bind"))]
186        if let Some(ref json_settings) = self.json_server_settings {
187            if !is_private_listen_addr(&json_settings.json_rpc_listen_address) {
188                return Err(IndexerError::ConfigError(
189                    "JSON-RPC server may only bind to private or loopback addresses. \
190                     Build with the `allow_unencrypted_public_json_rpc_bind` feature to \
191                     override (trusted private networks only)."
192                        .to_string(),
193                ));
194            }
195        }
196
197        #[cfg(feature = "allow_unencrypted_public_json_rpc_bind")]
198        {
199            warn!(
200                "Zaino built with allow_unencrypted_public_json_rpc_bind: the JSON-RPC \
201                 server may bind to public addresses without encryption. Proceed with caution."
202            );
203        }
204
205        // Check gRPC and JsonRPC server are not listening on the same address.
206        if let Some(ref json_settings) = self.json_server_settings {
207            if json_settings.json_rpc_listen_address == self.grpc_settings.listen_address {
208                return Err(IndexerError::ConfigError(
209                    "gRPC server and JsonRPC server must listen on different addresses."
210                        .to_string(),
211                ));
212            }
213        }
214
215        Ok(())
216    }
217
218    /// Returns the network type currently being used by the server.
219    pub fn get_network(&self) -> Result<zebra_chain::parameters::Network, IndexerError> {
220        Ok(self.network.to_zebra_network())
221    }
222}
223
224impl Default for ZainodConfig {
225    fn default() -> Self {
226        Self {
227            backend: BackendType::default(),
228            metrics_endpoint: None,
229            json_server_settings: None,
230            grpc_settings: GrpcServerConfig {
231                listen_address: "127.0.0.1:8137".parse().unwrap(),
232                tls: None,
233            },
234            validator_settings: ValidatorConfig {
235                validator_grpc_listen_address: Some("127.0.0.1:18230".to_string()),
236                validator_jsonrpc_listen_address: "127.0.0.1:18232".to_string(),
237                validator_cookie_path: None,
238                validator_user: Some("xxxxxx".to_string()),
239                validator_password: Some("xxxxxx".to_string()),
240            },
241            service: ServiceConfig::default(),
242            storage: StorageConfig::default(),
243            zebra_db_path: default_zebra_db_path(),
244            network: Network::Testnet,
245            donation_address: None,
246        }
247    }
248}
249
250/// Returns the default path for Zaino's ephemeral authentication cookie.
251pub fn default_ephemeral_cookie_path() -> PathBuf {
252    zaino_common::xdg::resolve_path_with_xdg_runtime_defaults("zaino/.cookie")
253}
254
255/// Loads the default file path for zebra's local db.
256pub fn default_zebra_db_path() -> PathBuf {
257    zaino_common::xdg::resolve_path_with_xdg_cache_defaults("zebra")
258}
259
260/// Resolves a hostname to a SocketAddr.
261fn fetch_socket_addr_from_hostname(address: &str) -> Result<SocketAddr, IndexerError> {
262    zaino_common::net::resolve_socket_addr(address)
263        .map_err(|e| IndexerError::ConfigError(format!("Invalid address '{address}': {e}")))
264}
265
266/// Validates that the configured `address` is either:
267/// - An RFC1918 (private) IPv4 address, or
268/// - An IPv6 Unique Local Address (ULA)
269pub(crate) fn is_private_listen_addr(addr: &SocketAddr) -> bool {
270    let ip = addr.ip();
271    match ip {
272        IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_loopback(),
273        IpAddr::V6(ipv6) => ipv6.is_unique_local() || ip.is_loopback(),
274    }
275}
276
277/// Loads configuration from a TOML file with optional environment variable overrides.
278///
279/// Configuration is layered: Defaults → TOML file → Environment variables (prefix: ZAINO_).
280/// Sensitive keys (password, secret, token, cookie, private_key) are blocked from env vars.
281pub fn load_config(file_path: &std::path::Path) -> Result<ZainodConfig, IndexerError> {
282    load_config_with_env(file_path, "ZAINO")
283}
284
285/// Loads configuration with a custom environment variable prefix.
286pub fn load_config_with_env(
287    file_path: &std::path::Path,
288    env_prefix: &str,
289) -> Result<ZainodConfig, IndexerError> {
290    // Check for sensitive keys in environment variables before loading
291    let required_prefix = format!("{}_", env_prefix);
292    for (key, _) in std::env::vars() {
293        if let Some(without_prefix) = key.strip_prefix(&required_prefix) {
294            if let Some(leaf) = without_prefix.split("__").last() {
295                if is_sensitive_leaf_key(leaf) {
296                    return Err(IndexerError::ConfigError(format!(
297                        "Environment variable '{}' contains sensitive key '{}' - use config file instead",
298                        key, leaf
299                    )));
300                }
301            }
302        }
303    }
304
305    let mut builder = config::Config::builder()
306        .set_default("backend", "fetch")
307        .map_err(|e| IndexerError::ConfigError(e.to_string()))?;
308
309    // Add TOML file source
310    builder = builder.add_source(
311        config::File::from(file_path)
312            .format(config::FileFormat::Toml)
313            .required(true),
314    );
315
316    // Add environment variable source with ZAINO_ prefix and __ separator for nesting
317    // Note: config-rs lowercases all env var keys after stripping the prefix
318    builder = builder.add_source(
319        config::Environment::with_prefix(env_prefix)
320            .prefix_separator("_")
321            .separator("__")
322            .try_parsing(true),
323    );
324
325    let settings = builder
326        .build()
327        .map_err(|e| IndexerError::ConfigError(format!("Configuration loading failed: {}", e)))?;
328
329    let mut parsed_config: ZainodConfig = settings
330        .try_deserialize()
331        .map_err(|e| IndexerError::ConfigError(format!("Configuration parsing failed: {}", e)))?;
332
333    // Handle empty cookie_dir: if json_server_settings exists with empty cookie_dir, set default
334    if parsed_config
335        .json_server_settings
336        .as_ref()
337        .is_some_and(|json_settings| {
338            json_settings
339                .cookie_dir
340                .as_ref()
341                .is_some_and(|dir| dir.as_os_str().is_empty())
342        })
343    {
344        if let Some(ref mut json_config) = parsed_config.json_server_settings {
345            json_config.cookie_dir = Some(default_ephemeral_cookie_path());
346        }
347    }
348
349    parsed_config.check_config()?;
350    info!(
351        path = %file_path.display(),
352        "config loaded and validated"
353    );
354    Ok(parsed_config)
355}
356
357#[allow(deprecated)]
358impl TryFrom<ZainodConfig> for StateServiceConfig {
359    type Error = IndexerError;
360
361    fn try_from(cfg: ZainodConfig) -> Result<Self, Self::Error> {
362        let grpc_listen_address = cfg
363            .validator_settings
364            .validator_grpc_listen_address
365            .as_ref()
366            .ok_or_else(|| {
367                IndexerError::ConfigError(
368                    "Missing validator_grpc_listen_address in configuration".to_string(),
369                )
370            })?;
371
372        let validator_grpc_address =
373            fetch_socket_addr_from_hostname(grpc_listen_address).map_err(|e| {
374                let msg = match e {
375                    IndexerError::ConfigError(msg) => msg,
376                    other => other.to_string(),
377                };
378                IndexerError::ConfigError(format!(
379                    "Invalid validator_grpc_listen_address '{grpc_listen_address}': {msg}"
380                ))
381            })?;
382
383        let validator_state_config = zebra_state::Config {
384            cache_dir: cfg.zebra_db_path.clone(),
385            ephemeral: false,
386            delete_old_database: true,
387            debug_stop_at_height: None,
388            debug_validity_check_interval: None,
389            should_backup_non_finalized_state: true,
390            debug_skip_non_finalized_state_backup_task: false,
391        };
392        let validator_cookie_auth = cfg.validator_settings.validator_cookie_path.is_some();
393
394        Ok(StateServiceConfig {
395            common: build_common(cfg),
396            validator_state_config,
397            validator_grpc_address,
398            validator_cookie_auth,
399        })
400    }
401}
402
403#[allow(deprecated)]
404impl TryFrom<ZainodConfig> for FetchServiceConfig {
405    type Error = IndexerError;
406
407    fn try_from(cfg: ZainodConfig) -> Result<Self, Self::Error> {
408        Ok(FetchServiceConfig {
409            common: build_common(cfg),
410        })
411    }
412}
413
414fn build_common(cfg: ZainodConfig) -> CommonBackendConfig {
415    CommonBackendConfig {
416        validator_rpc_address: cfg.validator_settings.validator_jsonrpc_listen_address,
417        validator_cookie_path: cfg.validator_settings.validator_cookie_path,
418        validator_rpc_user: cfg
419            .validator_settings
420            .validator_user
421            .unwrap_or_else(|| "xxxxxx".to_string()),
422        validator_rpc_password: cfg
423            .validator_settings
424            .validator_password
425            .unwrap_or_else(|| "xxxxxx".to_string()),
426        service: cfg.service,
427        storage: cfg.storage,
428        network: cfg.network,
429        donation_address: cfg.donation_address,
430        indexer_version: env!("CARGO_PKG_VERSION").to_string(),
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use std::{env, sync::Mutex};
438    use tempfile::TempDir;
439
440    const ZAINO_ENV_PREFIX: &str = "ZAINO_";
441    static TEST_MUTEX: Mutex<()> = Mutex::new(());
442
443    /// RAII guard for managing environment variables in tests.
444    /// Ensures test isolation by clearing ZAINO_* vars before tests
445    /// and restoring original values after.
446    struct EnvGuard {
447        _guard: std::sync::MutexGuard<'static, ()>,
448        original_vars: Vec<(String, String)>,
449    }
450
451    impl EnvGuard {
452        fn new() -> Self {
453            let guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
454            let original_vars: Vec<_> = env::vars()
455                .filter(|(k, _)| k.starts_with(ZAINO_ENV_PREFIX))
456                .collect();
457            // Clear all ZAINO_* vars for test isolation
458            for (key, _) in &original_vars {
459                env::remove_var(key);
460            }
461            Self {
462                _guard: guard,
463                original_vars,
464            }
465        }
466
467        fn set_var(&self, key: &str, value: &str) {
468            env::set_var(key, value);
469        }
470    }
471
472    impl Drop for EnvGuard {
473        fn drop(&mut self) {
474            // Clear test vars
475            for (k, _) in env::vars().filter(|(k, _)| k.starts_with(ZAINO_ENV_PREFIX)) {
476                env::remove_var(&k);
477            }
478            // Restore originals
479            for (k, v) in &self.original_vars {
480                env::set_var(k, v);
481            }
482        }
483    }
484
485    fn create_test_config_file(dir: &TempDir, content: &str, filename: &str) -> PathBuf {
486        let path = dir.path().join(filename);
487        std::fs::write(&path, content).unwrap();
488        path
489    }
490
491    #[test]
492    fn test_deserialize_full_valid_config() {
493        let _guard = EnvGuard::new();
494        let temp_dir = TempDir::new().unwrap();
495
496        // Create mock files
497        let cert_file = temp_dir.path().join("test_cert.pem");
498        let key_file = temp_dir.path().join("test_key.pem");
499        let validator_cookie_file = temp_dir.path().join("validator.cookie");
500        let zaino_cookie_dir = temp_dir.path().join("zaino_cookies_dir");
501        let zaino_db_dir = temp_dir.path().join("zaino_db_dir");
502        let zebra_db_dir = temp_dir.path().join("zebra_db_dir");
503
504        std::fs::write(&cert_file, "mock cert content").unwrap();
505        std::fs::write(&key_file, "mock key content").unwrap();
506        std::fs::write(&validator_cookie_file, "mock validator cookie content").unwrap();
507        std::fs::create_dir_all(&zaino_cookie_dir).unwrap();
508        std::fs::create_dir_all(&zaino_db_dir).unwrap();
509        std::fs::create_dir_all(&zebra_db_dir).unwrap();
510
511        let toml_content = format!(
512            r#"
513backend = "fetch"
514zebra_db_path = "{}"
515network = "Mainnet"
516
517[storage.database]
518path = "{}"
519
520[validator_settings]
521validator_jsonrpc_listen_address = "192.168.1.10:18232"
522validator_cookie_path = "{}"
523validator_user = "user"
524validator_password = "password"
525
526[json_server_settings]
527json_rpc_listen_address = "127.0.0.1:8000"
528cookie_dir = "{}"
529
530[grpc_settings]
531listen_address = "0.0.0.0:9000"
532
533[grpc_settings.tls]
534cert_path = "{}"
535key_path = "{}"
536"#,
537            zebra_db_dir.display(),
538            zaino_db_dir.display(),
539            validator_cookie_file.display(),
540            zaino_cookie_dir.display(),
541            cert_file.display(),
542            key_file.display(),
543        );
544
545        let config_path = create_test_config_file(&temp_dir, &toml_content, "full_config.toml");
546        let config = load_config(&config_path).expect("load_config failed");
547
548        assert_eq!(config.backend, BackendType::Fetch);
549        assert!(config.json_server_settings.is_some());
550        assert_eq!(
551            config
552                .json_server_settings
553                .as_ref()
554                .unwrap()
555                .json_rpc_listen_address,
556            "127.0.0.1:8000".parse().unwrap()
557        );
558        assert_eq!(config.network, Network::Mainnet);
559        assert_eq!(
560            config.grpc_settings.listen_address,
561            "0.0.0.0:9000".parse().unwrap()
562        );
563        assert!(config.grpc_settings.tls.is_some());
564        assert_eq!(
565            config.validator_settings.validator_user,
566            Some("user".to_string())
567        );
568        assert_eq!(
569            config.validator_settings.validator_password,
570            Some("password".to_string())
571        );
572    }
573
574    #[test]
575    fn test_deserialize_optional_fields_missing() {
576        let _guard = EnvGuard::new();
577        let temp_dir = TempDir::new().unwrap();
578
579        let toml_content = r#"
580backend = "state"
581network = "Testnet"
582zebra_db_path = "/opt/zebra/data"
583
584[storage.database]
585path = "/opt/zaino/data"
586
587[validator_settings]
588validator_jsonrpc_listen_address = "127.0.0.1:18232"
589
590[grpc_settings]
591listen_address = "127.0.0.1:8137"
592"#;
593
594        let config_path = create_test_config_file(&temp_dir, toml_content, "optional_missing.toml");
595        let config = load_config(&config_path).expect("load_config failed");
596        let default_values = ZainodConfig::default();
597
598        assert_eq!(config.backend, BackendType::State);
599        assert!(config.json_server_settings.is_none());
600        assert_eq!(
601            config.validator_settings.validator_user,
602            default_values.validator_settings.validator_user
603        );
604        assert_eq!(
605            config.storage.cache.capacity,
606            default_values.storage.cache.capacity
607        );
608    }
609
610    #[test]
611    fn test_cookie_dir_logic() {
612        let _guard = EnvGuard::new();
613        let temp_dir = TempDir::new().unwrap();
614
615        // Scenario 1: auth enabled, cookie_dir empty (should use default ephemeral path)
616        let toml_content = r#"
617backend = "fetch"
618network = "Testnet"
619zebra_db_path = "/zebra/db"
620
621[storage.database]
622path = "/zaino/db"
623
624[json_server_settings]
625json_rpc_listen_address = "127.0.0.1:8237"
626cookie_dir = ""
627
628[validator_settings]
629validator_jsonrpc_listen_address = "127.0.0.1:18232"
630
631[grpc_settings]
632listen_address = "127.0.0.1:8137"
633"#;
634
635        let config_path = create_test_config_file(&temp_dir, toml_content, "s1.toml");
636        let config = load_config(&config_path).expect("Config S1 failed");
637        assert!(config.json_server_settings.is_some());
638        assert!(config
639            .json_server_settings
640            .as_ref()
641            .unwrap()
642            .cookie_dir
643            .is_some());
644
645        // Scenario 2: auth enabled, cookie_dir specified
646        let toml_content2 = r#"
647backend = "fetch"
648network = "Testnet"
649zebra_db_path = "/zebra/db"
650
651[storage.database]
652path = "/zaino/db"
653
654[json_server_settings]
655json_rpc_listen_address = "127.0.0.1:8237"
656cookie_dir = "/my/cookie/path"
657
658[validator_settings]
659validator_jsonrpc_listen_address = "127.0.0.1:18232"
660
661[grpc_settings]
662listen_address = "127.0.0.1:8137"
663"#;
664
665        let config_path2 = create_test_config_file(&temp_dir, toml_content2, "s2.toml");
666        let config2 = load_config(&config_path2).expect("Config S2 failed");
667        assert_eq!(
668            config2.json_server_settings.as_ref().unwrap().cookie_dir,
669            Some(PathBuf::from("/my/cookie/path"))
670        );
671
672        // Scenario 3: cookie_dir not specified (should be None)
673        let toml_content3 = r#"
674backend = "fetch"
675network = "Testnet"
676zebra_db_path = "/zebra/db"
677
678[storage.database]
679path = "/zaino/db"
680
681[json_server_settings]
682json_rpc_listen_address = "127.0.0.1:8237"
683
684[validator_settings]
685validator_jsonrpc_listen_address = "127.0.0.1:18232"
686
687[grpc_settings]
688listen_address = "127.0.0.1:8137"
689"#;
690
691        let config_path3 = create_test_config_file(&temp_dir, toml_content3, "s3.toml");
692        let config3 = load_config(&config_path3).expect("Config S3 failed");
693        assert!(config3.json_server_settings.unwrap().cookie_dir.is_none());
694    }
695
696    #[test]
697    fn test_deserialize_empty_string_yields_default() {
698        let _guard = EnvGuard::new();
699        let temp_dir = TempDir::new().unwrap();
700
701        // Minimal valid config
702        let toml_content = r#"
703[validator_settings]
704validator_jsonrpc_listen_address = "127.0.0.1:18232"
705
706[storage.database]
707path = "/zaino/db"
708
709[grpc_settings]
710listen_address = "127.0.0.1:8137"
711"#;
712
713        let config_path = create_test_config_file(&temp_dir, toml_content, "empty.toml");
714        let config = load_config(&config_path).expect("Empty TOML load failed");
715        let default_config = ZainodConfig::default();
716
717        assert_eq!(config.network, default_config.network);
718        assert_eq!(config.backend, default_config.backend);
719        assert_eq!(
720            config.storage.cache.capacity,
721            default_config.storage.cache.capacity
722        );
723    }
724
725    #[test]
726    fn test_deserialize_invalid_backend_type() {
727        let _guard = EnvGuard::new();
728        let temp_dir = TempDir::new().unwrap();
729
730        let toml_content = r#"
731backend = "invalid_type"
732
733[validator_settings]
734validator_jsonrpc_listen_address = "127.0.0.1:18232"
735
736[storage.database]
737path = "/zaino/db"
738
739[grpc_settings]
740listen_address = "127.0.0.1:8137"
741"#;
742
743        let config_path = create_test_config_file(&temp_dir, toml_content, "invalid_backend.toml");
744        let result = load_config(&config_path);
745        assert!(result.is_err());
746        if let Err(IndexerError::ConfigError(msg)) = result {
747            assert!(
748                msg.contains("unknown variant") || msg.contains("invalid_type"),
749                "Unexpected error message: {}",
750                msg
751            );
752        }
753    }
754
755    #[test]
756    fn test_deserialize_invalid_socket_address() {
757        let _guard = EnvGuard::new();
758        let temp_dir = TempDir::new().unwrap();
759
760        let toml_content = r#"
761[json_server_settings]
762json_rpc_listen_address = "not-a-valid-address"
763cookie_dir = ""
764
765[validator_settings]
766validator_jsonrpc_listen_address = "127.0.0.1:18232"
767
768[storage.database]
769path = "/zaino/db"
770
771[grpc_settings]
772listen_address = "127.0.0.1:8137"
773"#;
774
775        let config_path = create_test_config_file(&temp_dir, toml_content, "invalid_socket.toml");
776        let result = load_config(&config_path);
777        assert!(result.is_err());
778    }
779
780    #[test]
781    fn test_env_override_toml_and_defaults() {
782        let guard = EnvGuard::new();
783        let temp_dir = TempDir::new().unwrap();
784
785        let toml_content = r#"
786network = "Testnet"
787
788[validator_settings]
789validator_jsonrpc_listen_address = "127.0.0.1:18232"
790
791[storage.database]
792path = "/zaino/db"
793
794[grpc_settings]
795listen_address = "127.0.0.1:8137"
796"#;
797
798        guard.set_var("ZAINO_NETWORK", "Mainnet");
799        guard.set_var(
800            "ZAINO_JSON_SERVER_SETTINGS__JSON_RPC_LISTEN_ADDRESS",
801            "127.0.0.1:0",
802        );
803        guard.set_var("ZAINO_JSON_SERVER_SETTINGS__COOKIE_DIR", "/env/cookie/path");
804        guard.set_var("ZAINO_STORAGE__CACHE__CAPACITY", "12345");
805
806        let config_path = create_test_config_file(&temp_dir, toml_content, "test_config.toml");
807        let config = load_config(&config_path).expect("load_config should succeed");
808
809        assert_eq!(config.network, Network::Mainnet);
810        assert_eq!(config.storage.cache.capacity, 12345);
811        assert!(config.json_server_settings.is_some());
812        assert_eq!(
813            config.json_server_settings.as_ref().unwrap().cookie_dir,
814            Some(PathBuf::from("/env/cookie/path"))
815        );
816    }
817
818    #[test]
819    fn test_toml_overrides_defaults() {
820        let _guard = EnvGuard::new();
821        let temp_dir = TempDir::new().unwrap();
822
823        // json_server_settings without a listening address is forbidden
824        let toml_content = r#"
825network = "Regtest"
826
827[json_server_settings]
828json_rpc_listen_address = ""
829cookie_dir = ""
830
831[validator_settings]
832validator_jsonrpc_listen_address = "127.0.0.1:18232"
833
834[storage.database]
835path = "/zaino/db"
836
837[grpc_settings]
838listen_address = "127.0.0.1:8137"
839"#;
840
841        let config_path = create_test_config_file(&temp_dir, toml_content, "test_config.toml");
842        assert!(load_config(&config_path).is_err());
843    }
844
845    #[test]
846    fn test_invalid_env_var_type() {
847        let guard = EnvGuard::new();
848        let temp_dir = TempDir::new().unwrap();
849
850        let toml_content = r#"
851[validator_settings]
852validator_jsonrpc_listen_address = "127.0.0.1:18232"
853
854[storage.database]
855path = "/zaino/db"
856
857[grpc_settings]
858listen_address = "127.0.0.1:8137"
859"#;
860
861        guard.set_var("ZAINO_STORAGE__CACHE__CAPACITY", "not_a_number");
862
863        let config_path = create_test_config_file(&temp_dir, toml_content, "test_config.toml");
864        let result = load_config(&config_path);
865        assert!(result.is_err());
866    }
867
868    #[test]
869    fn test_cookie_auth_not_forced_for_non_loopback_ip() {
870        let _guard = EnvGuard::new();
871        let temp_dir = TempDir::new().unwrap();
872
873        let toml_content = r#"
874backend = "fetch"
875network = "Testnet"
876
877[validator_settings]
878validator_jsonrpc_listen_address = "192.168.1.10:18232"
879
880[storage.database]
881path = "/zaino/db"
882
883[grpc_settings]
884listen_address = "127.0.0.1:8137"
885"#;
886
887        let config_path = create_test_config_file(&temp_dir, toml_content, "no_cookie_auth.toml");
888        let config_result = load_config(&config_path);
889        assert!(
890            config_result.is_ok(),
891            "Non-loopback IP without cookie auth should succeed. Error: {:?}",
892            config_result.err()
893        );
894
895        let config = config_result.unwrap();
896        assert!(config.validator_settings.validator_cookie_path.is_none());
897    }
898
899    #[test]
900    fn test_public_ip_still_rejected() {
901        let _guard = EnvGuard::new();
902        let temp_dir = TempDir::new().unwrap();
903
904        let toml_content = r#"
905backend = "fetch"
906network = "Testnet"
907
908[validator_settings]
909validator_jsonrpc_listen_address = "8.8.8.8:18232"
910
911[storage.database]
912path = "/zaino/db"
913
914[grpc_settings]
915listen_address = "127.0.0.1:8137"
916"#;
917
918        let config_path = create_test_config_file(&temp_dir, toml_content, "public_ip.toml");
919        let result = load_config(&config_path);
920        assert!(result.is_err());
921
922        if let Err(IndexerError::ConfigError(msg)) = result {
923            assert!(msg.contains("private IP"));
924        }
925    }
926
927    #[test]
928    fn test_sensitive_env_var_blocked() {
929        let guard = EnvGuard::new();
930        let temp_dir = TempDir::new().unwrap();
931
932        let toml_content = r#"
933[validator_settings]
934validator_jsonrpc_listen_address = "127.0.0.1:18232"
935
936[storage.database]
937path = "/zaino/db"
938
939[grpc_settings]
940listen_address = "127.0.0.1:8137"
941"#;
942
943        guard.set_var("ZAINO_VALIDATOR_SETTINGS__VALIDATOR_PASSWORD", "secret123");
944
945        let config_path =
946            create_test_config_file(&temp_dir, toml_content, "sensitive_env_test.toml");
947        let result = load_config(&config_path);
948        assert!(result.is_err());
949
950        if let Err(IndexerError::ConfigError(msg)) = result {
951            assert!(msg.contains("sensitive key"));
952            assert!(msg.contains("VALIDATOR_PASSWORD"));
953        }
954    }
955
956    #[test]
957    fn test_sensitive_key_detection() {
958        assert!(is_sensitive_leaf_key("password"));
959        assert!(is_sensitive_leaf_key("PASSWORD"));
960        assert!(is_sensitive_leaf_key("validator_password"));
961        assert!(is_sensitive_leaf_key("VALIDATOR_PASSWORD"));
962        assert!(is_sensitive_leaf_key("secret"));
963        assert!(is_sensitive_leaf_key("api_token"));
964        assert!(is_sensitive_leaf_key("cookie"));
965        assert!(is_sensitive_leaf_key("private_key"));
966
967        assert!(!is_sensitive_leaf_key("username"));
968        assert!(!is_sensitive_leaf_key("address"));
969        assert!(!is_sensitive_leaf_key("network"));
970    }
971
972    #[test]
973    fn test_unknown_fields_rejected() {
974        let _guard = EnvGuard::new();
975        let temp_dir = TempDir::new().unwrap();
976
977        let toml_content = r#"
978unknown_field = "value"
979
980[validator_settings]
981validator_jsonrpc_listen_address = "127.0.0.1:18232"
982
983[storage.database]
984path = "/zaino/db"
985
986[grpc_settings]
987listen_address = "127.0.0.1:8137"
988"#;
989
990        let config_path = create_test_config_file(&temp_dir, toml_content, "unknown_fields.toml");
991        let result = load_config(&config_path);
992        assert!(result.is_err());
993    }
994
995    /// Verifies that `generate_default_config()` produces valid TOML.
996    ///
997    /// TOML requires simple values before table sections. If ZainodConfig field
998    /// order changes incorrectly, serialization fails with "values must be
999    /// emitted before tables". This test catches that regression.
1000    #[test]
1001    fn test_generate_default_config_produces_valid_toml() {
1002        let content = generate_default_config().expect("should generate config");
1003        assert!(content.starts_with(GENERATED_CONFIG_HEADER));
1004
1005        let toml_part = content.strip_prefix(GENERATED_CONFIG_HEADER).unwrap();
1006        let parsed: Result<toml::Value, _> = toml::from_str(toml_part);
1007        assert!(
1008            parsed.is_ok(),
1009            "Generated config is not valid TOML: {:?}",
1010            parsed.err()
1011        );
1012    }
1013
1014    /// Verifies config survives serialize → deserialize → serialize roundtrip.
1015    ///
1016    /// Catches regressions in custom serde impls (DatabaseSize, Network) and
1017    /// ensures field ordering remains stable. If the second serialization differs
1018    /// from the first, something is being lost or transformed during the roundtrip.
1019    #[test]
1020    fn test_config_roundtrip_serialize_deserialize() {
1021        let original = ZainodConfig::default();
1022
1023        let toml_str = toml::to_string_pretty(&original).expect("should serialize");
1024        let roundtripped: ZainodConfig = toml::from_str(&toml_str).expect("should deserialize");
1025        let toml_str_again = toml::to_string_pretty(&roundtripped).expect("should serialize again");
1026
1027        assert_eq!(
1028            toml_str, toml_str_again,
1029            "config roundtrip should be stable"
1030        );
1031    }
1032
1033    // --- donation_address ---
1034
1035    #[test]
1036    fn donation_address_valid_is_accepted() {
1037        use zcash_address::{ToAddress as _, ZcashAddress};
1038        use zcash_protocol::consensus::NetworkType;
1039
1040        let _guard = EnvGuard::new();
1041        let dir = TempDir::new().unwrap();
1042
1043        let valid_addr =
1044            ZcashAddress::from_transparent_p2pkh(NetworkType::Main, [1u8; 20]).encode();
1045
1046        let content = format!(
1047            "donation_address = {:?}\n\
1048             [grpc_settings]\n\
1049             listen_address = \"127.0.0.1:8232\"\n",
1050            valid_addr,
1051        );
1052        let path = create_test_config_file(&dir, &content, "valid_donation.toml");
1053        let cfg = load_config(&path).unwrap();
1054        assert_eq!(cfg.donation_address.unwrap().to_string(), valid_addr);
1055    }
1056
1057    #[test]
1058    fn donation_address_invalid_is_rejected() {
1059        let _guard = EnvGuard::new();
1060        let dir = TempDir::new().unwrap();
1061
1062        let content = "donation_address = \"not_a_zcash_address\"\n\
1063             [grpc_settings]\n\
1064             listen_address = \"127.0.0.1:8232\"\n";
1065        let path = create_test_config_file(&dir, content, "invalid_donation.toml");
1066        assert!(load_config(&path).is_err());
1067    }
1068
1069    /// `LightdInfo.version` (issue #1057) is sourced from
1070    /// `*ServiceConfig.indexer_version`. This must be set to `zainod`'s
1071    /// `CARGO_PKG_VERSION` at the boundary so the wire reflects the
1072    /// deployed binary, not zaino-state's library version.
1073    #[test]
1074    #[allow(deprecated)]
1075    fn indexer_version_is_zainod_pkg_version() {
1076        let _guard = EnvGuard::new();
1077
1078        let cfg = ZainodConfig::default();
1079
1080        let state_cfg = StateServiceConfig::try_from(cfg.clone())
1081            .expect("StateServiceConfig conversion should succeed for default ZainodConfig");
1082        assert_eq!(state_cfg.common.indexer_version, env!("CARGO_PKG_VERSION"));
1083
1084        let fetch_cfg = FetchServiceConfig::try_from(cfg)
1085            .expect("FetchServiceConfig conversion should succeed for default ZainodConfig");
1086        assert_eq!(fetch_cfg.common.indexer_version, env!("CARGO_PKG_VERSION"));
1087    }
1088
1089    /// `StateServiceConfig::try_from` and `FetchServiceConfig::try_from`
1090    /// share a single `build_common` helper, so the two backends can
1091    /// never quietly disagree on the common payload they hand to a
1092    /// service. Locks that property in across every field: a future
1093    /// hand-rolled divergence (e.g. one path stops applying the
1094    /// missing-credentials sentinel, or a new common field gets
1095    /// populated on only one side) makes this fail. Pretty-Debug
1096    /// equality is used because not every constituent of
1097    /// `CommonBackendConfig` derives `PartialEq`, and a single
1098    /// stringified compare future-proofs the test against fields added
1099    /// later.
1100    #[test]
1101    #[allow(deprecated)]
1102    fn state_and_fetch_common_payloads_agree() {
1103        let _guard = EnvGuard::new();
1104
1105        let cfg = ZainodConfig::default();
1106
1107        let state_cfg = StateServiceConfig::try_from(cfg.clone())
1108            .expect("StateServiceConfig conversion should succeed for default ZainodConfig");
1109        let fetch_cfg = FetchServiceConfig::try_from(cfg)
1110            .expect("FetchServiceConfig conversion should succeed for default ZainodConfig");
1111
1112        assert_eq!(
1113            format!("{:#?}", state_cfg.common),
1114            format!("{:#?}", fetch_cfg.common),
1115        );
1116    }
1117
1118    /// Builds a default config with the JSON-RPC server bound to `addr`.
1119    ///
1120    /// The default config otherwise passes `check_config` (loopback gRPC,
1121    /// private validator), so the JSON-RPC bind address is isolated as the only
1122    /// variable under test. A non-default port avoids the gRPC/JSON-RPC
1123    /// same-address check.
1124    fn json_config_with(addr: &str) -> ZainodConfig {
1125        ZainodConfig {
1126            json_server_settings: Some(JsonRpcServerConfig {
1127                json_rpc_listen_address: addr.parse().expect("test bind address must parse"),
1128                cookie_dir: None,
1129            }),
1130            ..ZainodConfig::default()
1131        }
1132    }
1133
1134    #[test]
1135    fn json_rpc_loopback_bind_is_accepted() {
1136        json_config_with("127.0.0.1:8237")
1137            .check_config()
1138            .expect("loopback JSON-RPC bind must be accepted");
1139    }
1140
1141    #[test]
1142    fn json_rpc_private_ipv4_bind_is_accepted() {
1143        json_config_with("192.168.1.10:8237")
1144            .check_config()
1145            .expect("RFC1918 JSON-RPC bind must be accepted");
1146    }
1147
1148    #[test]
1149    fn json_rpc_ipv6_ula_bind_is_accepted() {
1150        json_config_with("[fc00::1]:8237")
1151            .check_config()
1152            .expect("IPv6 ULA JSON-RPC bind must be accepted");
1153    }
1154
1155    #[test]
1156    fn no_json_server_settings_is_accepted() {
1157        let cfg = ZainodConfig {
1158            json_server_settings: None,
1159            ..ZainodConfig::default()
1160        };
1161        cfg.check_config()
1162            .expect("config without a JSON-RPC server must be accepted");
1163    }
1164
1165    // The rejection rule is compiled out when the override feature is enabled,
1166    // so these tests only apply to the default build.
1167    #[cfg(not(feature = "allow_unencrypted_public_json_rpc_bind"))]
1168    #[test]
1169    fn json_rpc_public_bind_is_rejected() {
1170        match json_config_with("8.8.8.8:8237").check_config() {
1171            Err(IndexerError::ConfigError(msg)) => assert!(
1172                msg.contains("allow_unencrypted_public_json_rpc_bind"),
1173                "error should name the override feature, got: {msg}"
1174            ),
1175            other => panic!("expected ConfigError for public JSON-RPC bind, got {other:?}"),
1176        }
1177    }
1178
1179    #[cfg(not(feature = "allow_unencrypted_public_json_rpc_bind"))]
1180    #[test]
1181    fn json_rpc_unspecified_bind_is_rejected() {
1182        // 0.0.0.0 binds all interfaces (including public) and is not private.
1183        match json_config_with("0.0.0.0:8237").check_config() {
1184            Err(IndexerError::ConfigError(_)) => {}
1185            other => panic!("expected ConfigError for unspecified JSON-RPC bind, got {other:?}"),
1186        }
1187    }
1188
1189    #[cfg(feature = "allow_unencrypted_public_json_rpc_bind")]
1190    #[test]
1191    fn json_rpc_public_bind_allowed_with_feature() {
1192        json_config_with("8.8.8.8:8237")
1193            .check_config()
1194            .expect("public JSON-RPC bind must be accepted under the override feature");
1195    }
1196}