1use std::{
4 net::{IpAddr, SocketAddr},
5 path::PathBuf,
6};
7
8pub 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
35#[serde(rename_all = "lowercase")]
36pub enum BackendType {
37 #[serde(alias = "state")]
41 Direct,
42 #[default]
46 #[serde(alias = "fetch")]
47 Rpc,
48}
49
50pub 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
65pub 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
77const SENSITIVE_KEY_SUFFIXES: [&str; 5] = ["password", "secret", "token", "cookie", "private_key"];
79
80fn 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#[derive(Debug, Clone, Deserialize, Serialize)]
92#[serde(deny_unknown_fields, default)]
93pub struct ZainodConfig {
94 pub backend: BackendType,
97 pub zebra_db_path: PathBuf,
101 pub ephemeral_finalised_state: bool,
106 pub network: Network,
109 pub metrics_endpoint: Option<SocketAddr>,
114
115 pub json_server_settings: Option<JsonRpcServerConfig>,
118 pub grpc_settings: GrpcServerConfig,
120 pub validator_settings: ValidatorConfig,
122 pub service: ServiceConfig,
124 pub storage: StorageConfig,
126 pub donation_address: Option<DonationAddress>,
128}
129
130impl ZainodConfig {
131 pub(crate) fn check_config(&self) -> Result<(), IndexerError> {
133 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 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 let validator_addr_result =
167 try_resolve_address(&self.validator_settings.validator_jsonrpc_listen_address);
168
169 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 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 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 #[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 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
276pub fn default_ephemeral_cookie_path() -> PathBuf {
278 zaino_common::xdg::resolve_path_with_xdg_runtime_defaults("zaino/.cookie")
279}
280
281pub fn default_zebra_db_path() -> PathBuf {
283 zaino_common::xdg::resolve_path_with_xdg_cache_defaults("zebra")
284}
285
286fn 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
292pub(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
303pub fn load_config(file_path: &std::path::Path) -> Result<ZainodConfig, IndexerError> {
308 load_config_with_env(file_path, "ZAINO")
309}
310
311pub fn load_config_with_env(
313 file_path: &std::path::Path,
314 env_prefix: &str,
315) -> Result<ZainodConfig, IndexerError> {
316 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 builder = builder.add_source(
337 config::File::from(file_path)
338 .format(config::FileFormat::Toml)
339 .required(true),
340 );
341
342 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 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 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 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 for (k, _) in env::vars().filter(|(k, _)| k.starts_with(ZAINO_ENV_PREFIX)) {
500 env::remove_var(&k);
501 }
502 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 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 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 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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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}