Skip to main content

zainodlib/
config.rs

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