Skip to main content

confium_coordinator/
config_validator.rs

1//! Configuration validator — pre-flight checks for coordinator/daemon configs.
2
3use serde::{Deserialize, Serialize};
4
5/// Result of configuration validation.
6#[derive(Debug, Clone)]
7pub struct ValidationResult {
8    /// True if all checks passed.
9    pub valid: bool,
10    /// List of issues found (empty if valid).
11    pub issues: Vec<ConfigIssue>,
12}
13
14/// A single configuration issue.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ConfigIssue {
17    /// Which field has the problem.
18    pub field: String,
19    /// What's wrong.
20    pub message: String,
21    /// Suggested fix.
22    pub suggestion: String,
23    /// Severity: "error" (blocks startup) or "warning".
24    pub severity: String,
25}
26
27impl ValidationResult {
28    /// Check if there are any errors (not just warnings).
29    pub fn has_errors(&self) -> bool {
30        self.issues.iter().any(|i| i.severity == "error")
31    }
32
33    /// Only error issues.
34    pub fn errors(&self) -> impl Iterator<Item = &ConfigIssue> {
35        self.issues.iter().filter(|i| i.severity == "error")
36    }
37}
38
39/// Validate a coordinator configuration.
40pub fn validate_coordinator_config(
41    addr: &str,
42    max_sessions: usize,
43    session_timeout_secs: u64,
44) -> ValidationResult {
45    let mut issues = Vec::new();
46
47    if addr.is_empty() {
48        issues.push(ConfigIssue {
49            field: "addr".into(),
50            message: "address is empty".into(),
51            suggestion: "set to e.g. '0.0.0.0:18432'".into(),
52            severity: "error".into(),
53        });
54    } else if !addr.contains(':') {
55        issues.push(ConfigIssue {
56            field: "addr".into(),
57            message: "address missing port".into(),
58            suggestion: "format: 'host:port'".into(),
59            severity: "error".into(),
60        });
61    }
62
63    if max_sessions == 0 {
64        issues.push(ConfigIssue {
65            field: "max_sessions".into(),
66            message: "max_sessions is 0 (unlimited)".into(),
67            suggestion: "set a positive limit for production".into(),
68            severity: "warning".into(),
69        });
70    }
71
72    if session_timeout_secs < 60 {
73        issues.push(ConfigIssue {
74            field: "session_timeout_secs".into(),
75            message: "timeout < 60 seconds may be too short".into(),
76            suggestion: "use at least 300 (5 min) for production".into(),
77            severity: "warning".into(),
78        });
79    }
80
81    ValidationResult {
82        valid: !issues.iter().any(|i| i.severity == "error"),
83        issues,
84    }
85}
86
87/// Validate a signer daemon configuration.
88pub fn validate_signer_config(
89    coordinator_addr: &str,
90    signer_id: &str,
91    quorum_id: &str,
92    share_file: &str,
93) -> ValidationResult {
94    let mut issues = Vec::new();
95
96    if signer_id.is_empty() {
97        issues.push(ConfigIssue {
98            field: "signer_id".into(),
99            message: "signer_id is empty".into(),
100            suggestion: "set a unique signer identity".into(),
101            severity: "error".into(),
102        });
103    }
104
105    if quorum_id.is_empty() {
106        issues.push(ConfigIssue {
107            field: "quorum_id".into(),
108            message: "quorum_id is empty".into(),
109            suggestion: "set the quorum this signer belongs to".into(),
110            severity: "error".into(),
111        });
112    }
113
114    if coordinator_addr.is_empty() || !coordinator_addr.contains(':') {
115        issues.push(ConfigIssue {
116            field: "coordinator_addr".into(),
117            message: "coordinator_addr is invalid".into(),
118            suggestion: "format: 'host:port'".into(),
119            severity: "error".into(),
120        });
121    }
122
123    if share_file.is_empty() {
124        issues.push(ConfigIssue {
125            field: "share_file".into(),
126            message: "share_file is empty".into(),
127            suggestion: "set path to the share JSON file".into(),
128            severity: "error".into(),
129        });
130    }
131
132    ValidationResult {
133        valid: !issues.iter().any(|i| i.severity == "error"),
134        issues,
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn valid_coordinator_config_passes() {
144        let result = validate_coordinator_config("0.0.0.0:18432", 100, 3600);
145        assert!(result.valid);
146        assert!(result.issues.is_empty());
147    }
148
149    #[test]
150    fn empty_addr_rejected() {
151        let result = validate_coordinator_config("", 100, 3600);
152        assert!(!result.valid);
153        assert!(result.has_errors());
154    }
155
156    #[test]
157    fn missing_port_rejected() {
158        let result = validate_coordinator_config("localhost", 100, 3600);
159        assert!(!result.valid);
160    }
161
162    #[test]
163    fn unlimited_sessions_warns() {
164        let result = validate_coordinator_config("0.0.0.0:80", 0, 3600);
165        assert!(result.valid); // warning, not error
166        assert!(!result.has_errors());
167    }
168
169    #[test]
170    fn short_timeout_warns() {
171        let result = validate_coordinator_config("0.0.0.0:80", 10, 30);
172        assert!(result.valid);
173        assert!(
174            result
175                .issues
176                .iter()
177                .any(|i| i.field == "session_timeout_secs")
178        );
179    }
180
181    #[test]
182    fn valid_signer_config_passes() {
183        let result =
184            validate_signer_config("localhost:18432", "alice", "quorum-1", "/shares/a.json");
185        assert!(result.valid);
186    }
187
188    #[test]
189    fn empty_signer_id_rejected() {
190        let result = validate_signer_config("localhost:18432", "", "q", "/s.json");
191        assert!(!result.valid);
192    }
193
194    #[test]
195    fn empty_share_file_rejected() {
196        let result = validate_signer_config("localhost:18432", "alice", "q", "");
197        assert!(!result.valid);
198    }
199
200    #[test]
201    fn issue_has_suggestion() {
202        let result = validate_coordinator_config("", 10, 3600);
203        for issue in &result.issues {
204            assert!(!issue.suggestion.is_empty());
205        }
206    }
207
208    #[test]
209    fn errors_filtered_correctly() {
210        let result = validate_coordinator_config("", 0, 30);
211        let errors: Vec<_> = result.errors().collect();
212        assert!(errors.iter().any(|e| e.severity == "error"));
213        assert!(result.issues.iter().any(|i| i.severity == "warning"));
214    }
215}