Skip to main content

rch_common/config/
validate.rs

1//! Configuration validation on startup.
2//!
3//! Validates all configuration values on startup and reports warnings/errors.
4
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// Configuration warning or error detected during validation.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ConfigWarning {
11    /// The environment variable or config key involved.
12    pub var: String,
13    /// Human-readable description of the issue.
14    pub message: String,
15    /// Severity level.
16    pub severity: Severity,
17}
18
19impl ConfigWarning {
20    /// Create a new warning.
21    pub fn warning(var: impl Into<String>, message: impl Into<String>) -> Self {
22        Self {
23            var: var.into(),
24            message: message.into(),
25            severity: Severity::Warning,
26        }
27    }
28
29    /// Create a new error.
30    pub fn error(var: impl Into<String>, message: impl Into<String>) -> Self {
31        Self {
32            var: var.into(),
33            message: message.into(),
34            severity: Severity::Error,
35        }
36    }
37
38    /// Create a new info message.
39    pub fn info(var: impl Into<String>, message: impl Into<String>) -> Self {
40        Self {
41            var: var.into(),
42            message: message.into(),
43            severity: Severity::Info,
44        }
45    }
46}
47
48impl std::fmt::Display for ConfigWarning {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "[{}] {}: {}", self.severity, self.var, self.message)
51    }
52}
53
54/// Severity level for configuration warnings.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Severity {
58    /// Informational message.
59    Info,
60    /// Warning that may cause issues.
61    Warning,
62    /// Error that will likely cause failures.
63    Error,
64}
65
66impl std::fmt::Display for Severity {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Severity::Info => write!(f, "INFO"),
70            Severity::Warning => write!(f, "WARN"),
71            Severity::Error => write!(f, "ERROR"),
72        }
73    }
74}
75
76/// Configuration values to validate.
77#[derive(Debug, Clone, Default)]
78pub struct ConfigToValidate {
79    /// Daemon socket timeout in milliseconds.
80    pub daemon_timeout_ms: Option<u64>,
81    /// Zstd compression level (1-22).
82    pub zstd_level: Option<i32>,
83    /// SSH key path.
84    pub ssh_key_path: Option<PathBuf>,
85    /// Whether mock SSH is enabled.
86    pub mock_ssh: bool,
87    /// Whether test mode is enabled.
88    pub test_mode: bool,
89    /// Circuit breaker failure threshold.
90    pub circuit_failure_threshold: Option<u32>,
91    /// Circuit breaker reset timeout in seconds.
92    pub circuit_reset_timeout_sec: Option<u64>,
93    /// Log level.
94    pub log_level: Option<String>,
95}
96
97/// Validate all configuration on startup.
98///
99/// Returns a list of warnings and errors. Errors should generally
100/// prevent the application from starting.
101pub fn validate_config(config: &ConfigToValidate) -> Vec<ConfigWarning> {
102    let mut warnings = Vec::new();
103
104    // Validate daemon timeout
105    if let Some(timeout_ms) = config.daemon_timeout_ms {
106        if timeout_ms < 100 {
107            warnings.push(ConfigWarning::warning(
108                "RCH_DAEMON_TIMEOUT_MS",
109                "Timeout less than 100ms may cause premature failures",
110            ));
111        }
112        if timeout_ms > 60000 {
113            warnings.push(ConfigWarning::warning(
114                "RCH_DAEMON_TIMEOUT_MS",
115                "Timeout greater than 60s may cause unresponsive behavior",
116            ));
117        }
118    }
119
120    // Validate zstd compression level
121    if let Some(level) = config.zstd_level {
122        if level > 19 {
123            warnings.push(ConfigWarning::warning(
124                "RCH_TRANSFER_ZSTD_LEVEL",
125                "Zstd level > 19 uses excessive CPU for minimal gain",
126            ));
127        }
128        if level < 1 {
129            warnings.push(ConfigWarning::warning(
130                "RCH_TRANSFER_ZSTD_LEVEL",
131                "Zstd level < 1 is invalid, using default",
132            ));
133        }
134    }
135
136    // Validate SSH key path
137    if let Some(ref key_path) = config.ssh_key_path
138        && !config.mock_ssh
139    {
140        if !key_path.exists() {
141            warnings.push(ConfigWarning::error(
142                "RCH_SSH_KEY",
143                format!("SSH key not found: {:?}", key_path),
144            ));
145        } else if key_path.is_dir() {
146            warnings.push(ConfigWarning::error(
147                "RCH_SSH_KEY",
148                format!("SSH key path is a directory, not a file: {:?}", key_path),
149            ));
150        } else if key_path.is_symlink() {
151            // Resolve symlink and verify it points to a valid file
152            match key_path.canonicalize() {
153                Ok(canonical) => {
154                    if !canonical.is_file() {
155                        warnings.push(ConfigWarning::error(
156                            "RCH_SSH_KEY",
157                            format!(
158                                "SSH key symlink {:?} resolves to non-file: {:?}",
159                                key_path, canonical
160                            ),
161                        ));
162                    }
163                    // Note: We don't restrict symlink targets - users may have valid
164                    // reasons to symlink keys from different locations
165                }
166                Err(e) => {
167                    warnings.push(ConfigWarning::error(
168                        "RCH_SSH_KEY",
169                        format!("SSH key symlink {:?} cannot be resolved: {}", key_path, e),
170                    ));
171                }
172            }
173        } else if !key_path.is_file() {
174            // Not a regular file (could be a socket, device, etc.)
175            warnings.push(ConfigWarning::error(
176                "RCH_SSH_KEY",
177                format!("SSH key path is not a regular file: {:?}", key_path),
178            ));
179        }
180        // Note: We don't check read permissions here because:
181        // 1. SSH will give a clear error if the key is unreadable
182        // 2. Permission checks have TOCTOU issues (permissions could change)
183        // 3. The file might be readable by the user but not by the process checking
184    }
185
186    // Validate mock SSH usage
187    if config.mock_ssh && !config.test_mode {
188        warnings.push(ConfigWarning::warning(
189            "RCH_MOCK_SSH",
190            "Mock SSH enabled outside test mode - builds won't actually compile remotely",
191        ));
192    }
193
194    // Validate circuit breaker settings
195    if let Some(threshold) = config.circuit_failure_threshold {
196        if threshold == 0 {
197            warnings.push(ConfigWarning::warning(
198                "RCH_CIRCUIT_FAILURE_THRESHOLD",
199                "Threshold of 0 means circuit will never open",
200            ));
201        }
202        if threshold > 100 {
203            warnings.push(ConfigWarning::warning(
204                "RCH_CIRCUIT_FAILURE_THRESHOLD",
205                "Very high threshold may delay failure detection",
206            ));
207        }
208    }
209
210    if let Some(timeout_sec) = config.circuit_reset_timeout_sec {
211        if timeout_sec < 5 {
212            warnings.push(ConfigWarning::warning(
213                "RCH_CIRCUIT_RESET_TIMEOUT_SEC",
214                "Reset timeout < 5s may cause rapid circuit state changes",
215            ));
216        }
217        if timeout_sec > 600 {
218            warnings.push(ConfigWarning::warning(
219                "RCH_CIRCUIT_RESET_TIMEOUT_SEC",
220                "Reset timeout > 10 minutes may delay recovery",
221            ));
222        }
223    }
224
225    // Validate log level
226    if let Some(ref level) = config.log_level {
227        let valid_levels = ["trace", "debug", "info", "warn", "error", "off"];
228        if !valid_levels.contains(&level.to_lowercase().as_str()) {
229            warnings.push(ConfigWarning::error(
230                "RCH_LOG_LEVEL",
231                format!(
232                    "Invalid log level '{}', expected one of: {:?}",
233                    level, valid_levels
234                ),
235            ));
236        }
237    }
238
239    warnings
240}
241
242/// Check if there are any errors in the warnings list.
243pub fn has_errors(warnings: &[ConfigWarning]) -> bool {
244    warnings
245        .iter()
246        .any(|w| matches!(w.severity, Severity::Error))
247}
248
249/// Filter warnings by severity.
250pub fn filter_by_severity(warnings: &[ConfigWarning], severity: Severity) -> Vec<&ConfigWarning> {
251    warnings.iter().filter(|w| w.severity == severity).collect()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_validate_low_timeout() {
260        let config = ConfigToValidate {
261            daemon_timeout_ms: Some(50),
262            ..Default::default()
263        };
264        let warnings = validate_config(&config);
265        assert!(warnings.iter().any(|w| w.var == "RCH_DAEMON_TIMEOUT_MS"));
266    }
267
268    #[test]
269    fn test_validate_high_zstd_level() {
270        let config = ConfigToValidate {
271            zstd_level: Some(20),
272            ..Default::default()
273        };
274        let warnings = validate_config(&config);
275        assert!(warnings.iter().any(|w| w.var == "RCH_TRANSFER_ZSTD_LEVEL"));
276    }
277
278    #[test]
279    fn test_validate_mock_ssh_without_test_mode() {
280        let config = ConfigToValidate {
281            mock_ssh: true,
282            test_mode: false,
283            ..Default::default()
284        };
285        let warnings = validate_config(&config);
286        assert!(warnings.iter().any(|w| w.var == "RCH_MOCK_SSH"));
287    }
288
289    #[test]
290    fn test_validate_mock_ssh_with_test_mode() {
291        let config = ConfigToValidate {
292            mock_ssh: true,
293            test_mode: true,
294            ..Default::default()
295        };
296        let warnings = validate_config(&config);
297        assert!(!warnings.iter().any(|w| w.var == "RCH_MOCK_SSH"));
298    }
299
300    #[test]
301    fn test_validate_missing_ssh_key() {
302        let config = ConfigToValidate {
303            ssh_key_path: Some(PathBuf::from("/nonexistent/path/key")),
304            mock_ssh: false,
305            ..Default::default()
306        };
307        let warnings = validate_config(&config);
308        assert!(
309            warnings
310                .iter()
311                .any(|w| w.var == "RCH_SSH_KEY" && matches!(w.severity, Severity::Error))
312        );
313    }
314
315    #[test]
316    fn test_validate_missing_ssh_key_with_mock() {
317        let config = ConfigToValidate {
318            ssh_key_path: Some(PathBuf::from("/nonexistent/path/key")),
319            mock_ssh: true,
320            test_mode: true,
321            ..Default::default()
322        };
323        let warnings = validate_config(&config);
324        // Should NOT complain about missing key when mock SSH is enabled
325        assert!(!warnings.iter().any(|w| w.var == "RCH_SSH_KEY"));
326    }
327
328    #[test]
329    fn test_validate_circuit_breaker_zero_threshold() {
330        let config = ConfigToValidate {
331            circuit_failure_threshold: Some(0),
332            ..Default::default()
333        };
334        let warnings = validate_config(&config);
335        assert!(
336            warnings
337                .iter()
338                .any(|w| w.var == "RCH_CIRCUIT_FAILURE_THRESHOLD")
339        );
340    }
341
342    #[test]
343    fn test_validate_invalid_log_level() {
344        let config = ConfigToValidate {
345            log_level: Some("verbose".to_string()),
346            ..Default::default()
347        };
348        let warnings = validate_config(&config);
349        assert!(
350            warnings
351                .iter()
352                .any(|w| w.var == "RCH_LOG_LEVEL" && matches!(w.severity, Severity::Error))
353        );
354    }
355
356    #[test]
357    fn test_validate_valid_log_level() {
358        let config = ConfigToValidate {
359            log_level: Some("debug".to_string()),
360            ..Default::default()
361        };
362        let warnings = validate_config(&config);
363        assert!(!warnings.iter().any(|w| w.var == "RCH_LOG_LEVEL"));
364    }
365
366    #[test]
367    fn test_has_errors() {
368        let warnings = vec![
369            ConfigWarning::warning("A", "warning"),
370            ConfigWarning::info("B", "info"),
371        ];
372        assert!(!has_errors(&warnings));
373
374        let warnings_with_error = vec![
375            ConfigWarning::warning("A", "warning"),
376            ConfigWarning::error("B", "error"),
377        ];
378        assert!(has_errors(&warnings_with_error));
379    }
380
381    #[test]
382    fn test_filter_by_severity() {
383        let warnings = vec![
384            ConfigWarning::warning("A", "warning1"),
385            ConfigWarning::error("B", "error1"),
386            ConfigWarning::warning("C", "warning2"),
387            ConfigWarning::info("D", "info1"),
388        ];
389
390        let errors = filter_by_severity(&warnings, Severity::Error);
391        assert_eq!(errors.len(), 1);
392        assert_eq!(errors[0].var, "B");
393
394        let warnings_only = filter_by_severity(&warnings, Severity::Warning);
395        assert_eq!(warnings_only.len(), 2);
396    }
397}