opencode_cloud_core/config/
validation.rs1use super::schema::{Config, validate_bind_address};
6use console::style;
7
8#[derive(Debug, Clone)]
10pub struct ValidationError {
11 pub field: String,
13 pub message: String,
15 pub fix_command: String,
17}
18
19#[derive(Debug, Clone)]
21pub struct ValidationWarning {
22 pub field: String,
24 pub message: String,
26 pub fix_command: String,
28}
29
30pub fn validate_config(config: &Config) -> Result<Vec<ValidationWarning>, ValidationError> {
37 let mut warnings = Vec::new();
38
39 if config.opencode_web_port < 1024 {
41 return Err(ValidationError {
42 field: "opencode_web_port".to_string(),
43 message: "Port must be >= 1024 (non-privileged)".to_string(),
44 fix_command: "occ config set opencode_web_port 3000".to_string(),
45 });
46 }
47 if let Err(msg) = validate_bind_address(&config.bind_address) {
51 return Err(ValidationError {
52 field: "bind_address".to_string(),
53 message: msg,
54 fix_command: "occ config set bind_address 127.0.0.1".to_string(),
55 });
56 }
57
58 if config.boot_mode != "user" && config.boot_mode != "system" {
60 return Err(ValidationError {
61 field: "boot_mode".to_string(),
62 message: "boot_mode must be 'user' or 'system'".to_string(),
63 fix_command: "occ config set boot_mode user".to_string(),
64 });
65 }
66
67 if config.rate_limit_attempts == 0 {
69 return Err(ValidationError {
70 field: "rate_limit_attempts".to_string(),
71 message: "rate_limit_attempts must be > 0".to_string(),
72 fix_command: "occ config set rate_limit_attempts 5".to_string(),
73 });
74 }
75
76 if config.rate_limit_window_seconds == 0 {
77 return Err(ValidationError {
78 field: "rate_limit_window_seconds".to_string(),
79 message: "rate_limit_window_seconds must be > 0".to_string(),
80 fix_command: "occ config set rate_limit_window_seconds 60".to_string(),
81 });
82 }
83
84 if config.is_network_exposed()
88 && config.users.is_empty()
89 && !config.allow_unauthenticated_network
90 {
91 warnings.push(ValidationWarning {
92 field: "bind_address".to_string(),
93 message: "Network exposed without authentication".to_string(),
94 fix_command: "occ user add".to_string(),
95 });
96 }
97
98 if let Some(ref username) = config.auth_username {
100 if !username.is_empty() {
101 warnings.push(ValidationWarning {
102 field: "auth_username".to_string(),
103 message: "Legacy auth fields present; consider using 'occ user add' instead"
104 .to_string(),
105 fix_command: "occ config set auth_username ''".to_string(),
106 });
107 }
108 }
109
110 if let Some(ref password) = config.auth_password {
111 if !password.is_empty() {
112 warnings.push(ValidationWarning {
113 field: "auth_password".to_string(),
114 message: "Legacy auth fields present; consider using 'occ user add' instead"
115 .to_string(),
116 fix_command: "occ config set auth_password ''".to_string(),
117 });
118 }
119 }
120
121 Ok(warnings)
122}
123
124pub fn display_validation_error(error: &ValidationError) {
126 eprintln!();
127 eprintln!("{}", style("Error: Configuration error").red().bold());
128 eprintln!();
129 eprintln!(" {} {}", style("Field:").dim(), error.field);
130 eprintln!(" {} {}", style("Problem:").dim(), error.message);
131 eprintln!();
132 eprintln!("{}:", style("To fix, run").dim());
133 eprintln!(" {}", style(&error.fix_command).cyan());
134 eprintln!();
135}
136
137pub fn display_validation_warning(warning: &ValidationWarning) {
139 eprintln!();
140 eprintln!(
141 "{}",
142 style("Warning: Configuration warning").yellow().bold()
143 );
144 eprintln!();
145 eprintln!(" {} {}", style("Field:").dim(), warning.field);
146 eprintln!(" {} {}", style("Issue:").dim(), warning.message);
147 eprintln!();
148 eprintln!("{}:", style("To address, run").dim());
149 eprintln!(" {}", style(&warning.fix_command).cyan());
150 eprintln!();
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn test_valid_config_passes() {
159 let config = Config::default();
160 let result = validate_config(&config);
161 assert!(result.is_ok());
162 }
163
164 #[test]
165 fn test_port_too_low() {
166 let config = Config {
167 opencode_web_port: 80,
168 ..Config::default()
169 };
170 let result = validate_config(&config);
171 assert!(result.is_err());
172 let err = result.unwrap_err();
173 assert_eq!(err.field, "opencode_web_port");
174 assert!(err.message.contains("1024"));
175 }
176
177 #[test]
180 fn test_invalid_bind_address() {
181 let config = Config {
182 bind_address: "not-an-ip".to_string(),
183 ..Config::default()
184 };
185 let result = validate_config(&config);
186 assert!(result.is_err());
187 let err = result.unwrap_err();
188 assert_eq!(err.field, "bind_address");
189 }
190
191 #[test]
192 fn test_invalid_boot_mode() {
193 let config = Config {
194 boot_mode: "invalid".to_string(),
195 ..Config::default()
196 };
197 let result = validate_config(&config);
198 assert!(result.is_err());
199 let err = result.unwrap_err();
200 assert_eq!(err.field, "boot_mode");
201 }
202
203 #[test]
204 fn test_rate_limit_attempts_zero() {
205 let config = Config {
206 rate_limit_attempts: 0,
207 ..Config::default()
208 };
209 let result = validate_config(&config);
210 assert!(result.is_err());
211 let err = result.unwrap_err();
212 assert_eq!(err.field, "rate_limit_attempts");
213 }
214
215 #[test]
216 fn test_rate_limit_window_zero() {
217 let config = Config {
218 rate_limit_window_seconds: 0,
219 ..Config::default()
220 };
221 let result = validate_config(&config);
222 assert!(result.is_err());
223 let err = result.unwrap_err();
224 assert_eq!(err.field, "rate_limit_window_seconds");
225 }
226
227 #[test]
228 fn test_network_exposed_without_auth_warning() {
229 let config = Config {
230 bind_address: "0.0.0.0".to_string(),
231 users: Vec::new(),
232 allow_unauthenticated_network: false,
233 ..Config::default()
234 };
235 let result = validate_config(&config);
236 assert!(result.is_ok());
237 let warnings = result.unwrap();
238 assert!(!warnings.is_empty());
239 assert!(
240 warnings
241 .iter()
242 .any(|w| w.message.contains("Network exposed"))
243 );
244 }
245
246 #[test]
247 fn test_legacy_auth_username_warning() {
248 let config = Config {
249 auth_username: Some("admin".to_string()),
250 ..Config::default()
251 };
252 let result = validate_config(&config);
253 assert!(result.is_ok());
254 let warnings = result.unwrap();
255 assert!(!warnings.is_empty());
256 assert!(warnings.iter().any(|w| w.field == "auth_username"));
257 }
258
259 #[test]
260 fn test_legacy_auth_password_warning() {
261 let config = Config {
262 auth_password: Some("secret".to_string()),
263 ..Config::default()
264 };
265 let result = validate_config(&config);
266 assert!(result.is_ok());
267 let warnings = result.unwrap();
268 assert!(!warnings.is_empty());
269 assert!(warnings.iter().any(|w| w.field == "auth_password"));
270 }
271}