1use 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
26pub 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
41pub 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
53const SENSITIVE_KEY_SUFFIXES: [&str; 5] = ["password", "secret", "token", "cookie", "private_key"];
55
56fn 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#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(deny_unknown_fields, default)]
69pub struct ZainodConfig {
70 pub backend: BackendType,
73 pub zebra_db_path: PathBuf,
77 pub network: Network,
79 pub metrics_endpoint: Option<SocketAddr>,
84
85 pub json_server_settings: Option<JsonRpcServerConfig>,
88 pub grpc_settings: GrpcServerConfig,
90 pub validator_settings: ValidatorConfig,
92 pub service: ServiceConfig,
94 pub storage: StorageConfig,
96 pub donation_address: Option<DonationAddress>,
98}
99
100impl ZainodConfig {
101 pub(crate) fn check_config(&self) -> Result<(), IndexerError> {
103 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 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 let validator_addr_result =
137 try_resolve_address(&self.validator_settings.validator_jsonrpc_listen_address);
138
139 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 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 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 #[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 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 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
250pub fn default_ephemeral_cookie_path() -> PathBuf {
252 zaino_common::xdg::resolve_path_with_xdg_runtime_defaults("zaino/.cookie")
253}
254
255pub fn default_zebra_db_path() -> PathBuf {
257 zaino_common::xdg::resolve_path_with_xdg_cache_defaults("zebra")
258}
259
260fn 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
266pub(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
277pub fn load_config(file_path: &std::path::Path) -> Result<ZainodConfig, IndexerError> {
282 load_config_with_env(file_path, "ZAINO")
283}
284
285pub fn load_config_with_env(
287 file_path: &std::path::Path,
288 env_prefix: &str,
289) -> Result<ZainodConfig, IndexerError> {
290 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 builder = builder.add_source(
311 config::File::from(file_path)
312 .format(config::FileFormat::Toml)
313 .required(true),
314 );
315
316 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 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 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 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 for (k, _) in env::vars().filter(|(k, _)| k.starts_with(ZAINO_ENV_PREFIX)) {
476 env::remove_var(&k);
477 }
478 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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}