Skip to main content

sentri_core/
config.rs

1//! Configuration management for Sentri.
2//!
3//! Supports TOML-based configuration with environment variable substitution.
4//! All env vars are expanded at load time; missing vars cause an error.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::env;
9
10/// Error type for configuration issues.
11#[derive(Debug, Clone)]
12pub struct ConfigError {
13    /// Error message.
14    pub message: String,
15}
16
17impl std::fmt::Display for ConfigError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        write!(f, "Config error: {}", self.message)
20    }
21}
22
23impl std::error::Error for ConfigError {}
24
25/// Root configuration structure.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Config {
28    /// Metadata: name, version, description.
29    pub metadata: Metadata,
30
31    /// Chain endpoints and configuration.
32    pub chains: Vec<ChainConfig>,
33
34    /// Invariant definitions.
35    pub invariants: Vec<InvariantConfig>,
36
37    /// Alert routing and filtering.
38    #[serde(default)]
39    pub alert: AlertConfig,
40
41    /// Evaluation parameters.
42    #[serde(default)]
43    pub evaluation: EvaluationConfig,
44
45    /// Daemon mode settings.
46    #[serde(default)]
47    pub daemon: DaemonConfig,
48
49    /// Logging configuration.
50    #[serde(default)]
51    pub logging: LoggingConfig,
52
53    /// Metrics / observability.
54    #[serde(default)]
55    pub metrics: MetricsConfig,
56
57    /// Security settings.
58    #[serde(default)]
59    pub security: SecurityConfig,
60
61    /// Performance tuning.
62    #[serde(default)]
63    pub performance: PerformanceConfig,
64}
65
66impl Config {
67    /// Load configuration from a TOML file.
68    /// Environment variables in the format `${VAR_NAME}` are expanded.
69    ///
70    /// # Errors
71    /// Returns error if file cannot be read, TOML is invalid, or env var is missing.
72    pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
73        let content = std::fs::read_to_string(path)?;
74        Self::load_from_string(&content)
75    }
76
77    /// Load configuration from a TOML string.
78    /// Environment variables in the format `${VAR_NAME}` are expanded.
79    pub fn load_from_string(content: &str) -> anyhow::Result<Self> {
80        // Expand environment variables in the content
81        let expanded = Self::expand_env_vars(content).map_err(|e| anyhow::anyhow!("{}", e))?;
82
83        // Parse TOML
84        let config: Config = toml::from_str(&expanded)
85            .map_err(|e| anyhow::anyhow!("Failed to parse TOML: {}", e))?;
86
87        // Validate
88        config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
89
90        Ok(config)
91    }
92
93    /// Expand all ${VAR_NAME} patterns to environment variables.
94    fn expand_env_vars(content: &str) -> Result<String, ConfigError> {
95        let mut result = content.to_string();
96        let mut last_pos = 0;
97
98        // Find all ${...} patterns
99        while let Some(start) = result[last_pos..].find("${") {
100            let start = last_pos + start;
101            if let Some(end) = result[start..].find('}') {
102                let end = start + end;
103                let var_ref = &result[start + 2..end];
104
105                // Get environment variable
106                match env::var(var_ref) {
107                    Ok(value) => {
108                        result.replace_range(start..=end, &value);
109                        last_pos = start + value.len();
110                    }
111                    Err(_) => {
112                        return Err(ConfigError {
113                            message: format!(
114                                "Environment variable not set: {}. \
115                                 Please export it before running Sentri. \
116                                 E.g.: export {}=<value>",
117                                var_ref, var_ref
118                            ),
119                        });
120                    }
121                }
122            } else {
123                return Err(ConfigError {
124                    message: "Unclosed environment variable reference: ${".to_string(),
125                });
126            }
127        }
128
129        Ok(result)
130    }
131
132    /// Validate the configuration for consistency.
133    pub fn validate(&self) -> Result<(), ConfigError> {
134        // Validate chains
135        if self.chains.is_empty() {
136            return Err(ConfigError {
137                message: "At least one chain must be configured in [[chains]]".to_string(),
138            });
139        }
140
141        let chain_ids: Vec<&str> = self.chains.iter().map(|c| c.id.as_str()).collect();
142
143        // Check for duplicate chain IDs
144        for (i, id1) in chain_ids.iter().enumerate() {
145            for id2 in &chain_ids[i + 1..] {
146                if id1 == id2 {
147                    return Err(ConfigError {
148                        message: format!("Duplicate chain ID: {}", id1),
149                    });
150                }
151            }
152        }
153
154        // Validate invariants
155        if self.invariants.is_empty() {
156            return Err(ConfigError {
157                message: "At least one invariant must be configured in [[invariants]]".to_string(),
158            });
159        }
160
161        // Check invariant references
162        for inv in &self.invariants {
163            if !chain_ids.contains(&inv.chain.as_str()) {
164                return Err(ConfigError {
165                    message: format!(
166                        "Invariant '{}' references unknown chain '{}'. \
167                         Configured chains: {:?}",
168                        inv.name, inv.chain, chain_ids
169                    ),
170                });
171            }
172
173            // Check for duplicate names
174            let names: Vec<_> = self.invariants.iter().map(|i| &i.name).collect();
175            for (i, name1) in names.iter().enumerate() {
176                for name2 in &names[i + 1..] {
177                    if name1 == name2 {
178                        return Err(ConfigError {
179                            message: format!("Duplicate invariant name: {}", name1),
180                        });
181                    }
182                }
183            }
184        }
185
186        // Validate alert sinks
187        for sink in &self.alert.sinks {
188            sink.validate()?;
189        }
190
191        Ok(())
192    }
193
194    /// Get a chain configuration by ID.
195    pub fn get_chain(&self, id: &str) -> Option<&ChainConfig> {
196        self.chains.iter().find(|c| c.id == id)
197    }
198
199    /// Get invariants for a specific chain.
200    pub fn invariants_for_chain(&self, chain_id: &str) -> Vec<&InvariantConfig> {
201        self.invariants
202            .iter()
203            .filter(|inv| inv.chain == chain_id)
204            .collect()
205    }
206}
207
208/// Metadata section.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct Metadata {
211    /// Project name.
212    pub name: String,
213
214    /// Project version.
215    pub version: String,
216
217    /// Optional description.
218    #[serde(default)]
219    pub description: Option<String>,
220}
221
222/// Chain configuration.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct ChainConfig {
225    /// Unique identifier for this chain.
226    pub id: String,
227
228    /// Chain type: "evm", "solana", "cosmos".
229    #[serde(rename = "type")]
230    pub chain_type: String,
231
232    /// Chain ID (for EVM chains).
233    pub chain_id: u64,
234
235    /// Primary and fallback RPC URLs.
236    pub rpc_urls: Vec<String>,
237
238    /// Optional WebSocket URL.
239    #[serde(default)]
240    pub ws_url: Option<String>,
241
242    /// Poll interval in milliseconds.
243    #[serde(default = "default_poll_interval")]
244    pub poll_interval_ms: u64,
245
246    /// Request timeout in milliseconds.
247    #[serde(default = "default_timeout")]
248    pub timeout_ms: u64,
249
250    /// Retry configuration.
251    #[serde(default)]
252    pub retry: RetryConfig,
253
254    /// Connection pool configuration.
255    #[serde(default)]
256    pub pool: PoolConfig,
257}
258
259fn default_poll_interval() -> u64 {
260    12000
261}
262
263fn default_timeout() -> u64 {
264    30000
265}
266
267/// Retry configuration.
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct RetryConfig {
270    /// Maximum retry attempts.
271    #[serde(default = "default_max_attempts")]
272    pub max_attempts: u32,
273
274    /// Initial backoff in milliseconds.
275    #[serde(default = "default_initial_backoff")]
276    pub initial_backoff_ms: u64,
277
278    /// Maximum backoff in milliseconds.
279    #[serde(default = "default_max_backoff")]
280    pub max_backoff_ms: u64,
281}
282
283fn default_max_attempts() -> u32 {
284    3
285}
286
287fn default_initial_backoff() -> u64 {
288    100
289}
290
291fn default_max_backoff() -> u64 {
292    400
293}
294
295/// Connection pool configuration.
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297pub struct PoolConfig {
298    /// Maximum concurrent connections.
299    #[serde(default = "default_max_connections")]
300    pub max_connections: u32,
301
302    /// Keepalive duration in seconds.
303    #[serde(default = "default_keepalive")]
304    pub keepalive_seconds: u64,
305}
306
307fn default_max_connections() -> u32 {
308    10
309}
310
311fn default_keepalive() -> u64 {
312    90
313}
314
315/// Invariant configuration.
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct InvariantConfig {
318    /// Unique name for this invariant.
319    pub name: String,
320
321    /// Human-readable description.
322    #[serde(default)]
323    pub description: Option<String>,
324
325    /// Which chain to evaluate on.
326    pub chain: String,
327
328    /// Contract address.
329    pub contract: String,
330
331    /// The invariant condition (expression).
332    pub check: String,
333
334    /// Optional baseline block number.
335    #[serde(default)]
336    pub baseline_block: Option<u64>,
337
338    /// Severity: critical, high, medium, low.
339    #[serde(default = "default_severity")]
340    pub severity: String,
341
342    /// Optional tags for filtering.
343    #[serde(default)]
344    pub tags: Vec<String>,
345}
346
347fn default_severity() -> String {
348    "high".to_string()
349}
350
351/// Alert configuration.
352#[derive(Debug, Clone, Default, Serialize, Deserialize)]
353pub struct AlertConfig {
354    /// Throttle identical alerts (seconds).
355    #[serde(default = "default_throttle")]
356    pub throttle_seconds: u64,
357
358    /// Enable/disable all alerts.
359    #[serde(default = "default_alert_enabled")]
360    pub enabled: bool,
361
362    /// Log level for violations: error, warn, info.
363    #[serde(default = "default_log_level")]
364    pub log_level: String,
365
366    /// Alert sinks (where to send alerts).
367    #[serde(default)]
368    pub sinks: Vec<AlertSink>,
369}
370
371fn default_throttle() -> u64 {
372    300
373}
374
375fn default_alert_enabled() -> bool {
376    true
377}
378
379fn default_log_level() -> String {
380    "error".to_string()
381}
382
383/// Configuration for an alert sink.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct AlertSink {
386    /// Sink type: slack, webhook, email, etc.
387    #[serde(rename = "type")]
388    pub sink_type: String,
389
390    /// Optional name for this sink.
391    #[serde(default)]
392    pub name: Option<String>,
393
394    /// Optional Slack webhook URL.
395    #[serde(default)]
396    pub webhook_url: Option<String>,
397
398    /// Optional custom message template.
399    #[serde(default)]
400    pub message_template: Option<String>,
401
402    /// Optional severity filter.
403    #[serde(default)]
404    pub severities: Vec<String>,
405
406    /// Generic webhook URL.
407    #[serde(default)]
408    pub url: Option<String>,
409
410    /// Custom headers for webhook.
411    #[serde(default)]
412    pub headers: BTreeMap<String, String>,
413
414    /// HTTP method: post, put.
415    #[serde(default = "default_method")]
416    pub method: String,
417
418    /// Enable webhook retry.
419    #[serde(default)]
420    pub retry_enabled: bool,
421
422    /// Max retry attempts for webhook.
423    #[serde(default)]
424    pub retry_max_attempts: u32,
425}
426
427fn default_method() -> String {
428    "post".to_string()
429}
430
431impl AlertSink {
432    /// Validate this alert sink configuration.
433    pub fn validate(&self) -> Result<(), ConfigError> {
434        match self.sink_type.as_str() {
435            "slack" => {
436                if self.webhook_url.is_none() {
437                    return Err(ConfigError {
438                        message: "Slack sink requires 'webhook_url'".to_string(),
439                    });
440                }
441                Ok(())
442            }
443            "webhook" => {
444                if self.url.is_none() {
445                    return Err(ConfigError {
446                        message: "Webhook sink requires 'url'".to_string(),
447                    });
448                }
449                Ok(())
450            }
451            _ => Err(ConfigError {
452                message: format!("Unknown alert sink type: {}", self.sink_type),
453            }),
454        }
455    }
456}
457
458/// Evaluation configuration.
459#[derive(Debug, Clone, Default, Serialize, Deserialize)]
460pub struct EvaluationConfig {
461    /// Evaluation interval in seconds.
462    #[serde(default = "default_eval_interval")]
463    pub interval_secs: u64,
464
465    /// Evaluation timeout in seconds.
466    #[serde(default = "default_eval_timeout")]
467    pub timeout_secs: u64,
468
469    /// Mode: immediate or block_based.
470    #[serde(default = "default_eval_mode")]
471    pub mode: String,
472}
473
474fn default_eval_interval() -> u64 {
475    12
476}
477
478fn default_eval_timeout() -> u64 {
479    30
480}
481
482fn default_eval_mode() -> String {
483    "block_based".to_string()
484}
485
486/// Daemon mode configuration.
487#[derive(Debug, Clone, Default, Serialize, Deserialize)]
488pub struct DaemonConfig {
489    /// Enable daemon mode.
490    #[serde(default)]
491    pub enabled: bool,
492
493    /// Metrics HTTP server port.
494    #[serde(default = "default_metrics_port")]
495    pub metrics_port: u16,
496
497    /// Metrics server host.
498    #[serde(default = "default_metrics_host")]
499    pub metrics_host: String,
500
501    /// Enable health check endpoint.
502    #[serde(default = "default_health_check")]
503    pub health_check_enabled: bool,
504
505    /// Reload config on SIGHUP.
506    #[serde(default)]
507    pub reload_on_sighup: bool,
508
509    /// Graceful shutdown timeout in seconds.
510    #[serde(default = "default_shutdown_timeout")]
511    pub graceful_shutdown_timeout_secs: u64,
512}
513
514fn default_metrics_port() -> u16 {
515    9090
516}
517
518fn default_metrics_host() -> String {
519    "127.0.0.1".to_string()
520}
521
522fn default_health_check() -> bool {
523    true
524}
525
526fn default_shutdown_timeout() -> u64 {
527    5
528}
529
530/// Logging configuration.
531#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532pub struct LoggingConfig {
533    /// Log level: trace, debug, info, warn, error.
534    #[serde(default = "default_log_level")]
535    pub level: String,
536
537    /// Format: pretty or json.
538    #[serde(default = "default_format")]
539    pub format: String,
540
541    /// Optional log file path.
542    #[serde(default)]
543    pub file: Option<String>,
544
545    /// Maximum log file size in MB.
546    #[serde(default)]
547    pub max_size_mb: Option<u32>,
548
549    /// Maximum number of backup log files.
550    #[serde(default)]
551    pub max_backups: Option<u32>,
552
553    /// Redact sensitive fields from logs.
554    #[serde(default = "default_redact")]
555    pub redact_sensitive: bool,
556}
557
558fn default_format() -> String {
559    "pretty".to_string()
560}
561
562fn default_redact() -> bool {
563    true
564}
565
566/// Metrics configuration.
567#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568pub struct MetricsConfig {
569    /// Enable Prometheus metrics.
570    #[serde(default)]
571    pub enabled: bool,
572
573    /// Format: prometheus.
574    #[serde(default = "default_metrics_format")]
575    pub format: String,
576
577    /// Histogram buckets for latency (milliseconds).
578    #[serde(default = "default_histogram_buckets")]
579    pub histogram_buckets_ms: Vec<u64>,
580
581    /// Sample rate (0.0 to 1.0).
582    #[serde(default = "default_sample_rate")]
583    pub sample_rate: f64,
584}
585
586fn default_metrics_format() -> String {
587    "prometheus".to_string()
588}
589
590fn default_histogram_buckets() -> Vec<u64> {
591    vec![10, 50, 100, 500, 1000, 5000]
592}
593
594fn default_sample_rate() -> f64 {
595    1.0
596}
597
598/// Security configuration.
599#[derive(Debug, Clone, Default, Serialize, Deserialize)]
600pub struct SecurityConfig {
601    /// Redact sensitive values in logs.
602    #[serde(default = "default_redact")]
603    pub redact_sensitive_in_logs: bool,
604}
605
606/// Performance configuration.
607#[derive(Debug, Clone, Default, Serialize, Deserialize)]
608pub struct PerformanceConfig {
609    /// Number of evaluator worker threads.
610    #[serde(default = "default_eval_workers")]
611    pub eval_workers: u32,
612
613    /// Cache compiled invariant expressions.
614    #[serde(default)]
615    pub cache_expressions: bool,
616
617    /// Cache chain state snapshots.
618    #[serde(default)]
619    pub cache_state: bool,
620
621    /// State cache TTL in seconds.
622    #[serde(default = "default_cache_ttl")]
623    pub cache_ttl_secs: u64,
624}
625
626fn default_eval_workers() -> u32 {
627    4
628}
629
630fn default_cache_ttl() -> u64 {
631    60
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn test_config_validation() {
640        let minimal_toml = r#"
641            [metadata]
642            name = "test"
643            version = "1.0"
644
645            [[chains]]
646            id = "ethereum"
647            type = "evm"
648            chain_id = 1
649            rpc_urls = ["https://eth.example.com"]
650
651            [[invariants]]
652            name = "test_invariant"
653            chain = "ethereum"
654            contract = "0x1234"
655            check = "x > 0"
656        "#;
657
658        let config = Config::load_from_string(minimal_toml);
659        assert!(config.is_ok());
660    }
661
662    #[test]
663    fn test_missing_chain_reference() {
664        let invalid_toml = r#"
665            [metadata]
666            name = "test"
667            version = "1.0"
668
669            [[chains]]
670            id = "ethereum"
671            type = "evm"
672            chain_id = 1
673            rpc_urls = ["https://eth.example.com"]
674
675            [[invariants]]
676            name = "test_invariant"
677            chain = "solana"
678            contract = "0x1234"
679            check = "x > 0"
680        "#;
681
682        let config = Config::load_from_string(invalid_toml);
683        assert!(config.is_err());
684    }
685
686    #[test]
687    fn test_env_var_substitution() {
688        env::set_var("TEST_RPC_URL", "https://test.example.com");
689
690        let toml_with_var = r#"
691            [metadata]
692            name = "test"
693            version = "1.0"
694
695            [[chains]]
696            id = "ethereum"
697            type = "evm"
698            chain_id = 1
699            rpc_urls = ["${TEST_RPC_URL}"]
700
701            [[invariants]]
702            name = "test_invariant"
703            chain = "ethereum"
704            contract = "0x1234"
705            check = "x > 0"
706        "#;
707
708        let config = Config::load_from_string(toml_with_var);
709        assert!(config.is_ok());
710
711        let cfg = config.unwrap();
712        assert_eq!(cfg.chains[0].rpc_urls[0], "https://test.example.com");
713
714        env::remove_var("TEST_RPC_URL");
715    }
716
717    #[test]
718    fn test_missing_env_var() {
719        let toml_with_missing_var = r#"
720            [metadata]
721            name = "test"
722            version = "1.0"
723
724            [[chains]]
725            id = "ethereum"
726            type = "evm"
727            chain_id = 1
728            rpc_urls = ["${NONEXISTENT_VAR_12345"}"]
729
730            [[invariants]]
731            name = "test_invariant"
732            chain = "ethereum"
733            contract = "0x1234"
734            check = "x > 0"
735        "#;
736
737        let config = Config::load_from_string(toml_with_missing_var);
738        assert!(config.is_err());
739    }
740}