systemprompt_config/services/
validator.rs1use super::types::{DeployEnvironment, EnvironmentConfig};
2use anyhow::{anyhow, Result};
3use regex::Regex;
4use std::path::Path;
5use systemprompt_logging::CliService;
6
7#[derive(Debug, Clone, Copy)]
8pub struct ConfigValidator;
9
10impl ConfigValidator {
11 pub fn validate(config: &EnvironmentConfig) -> Result<ValidationReport> {
12 let mut report = ValidationReport::new();
13
14 CliService::section(&format!(
15 "Validating Environment: {}",
16 config.environment.as_str()
17 ));
18
19 Self::check_unresolved_variables(config, &mut report);
20 Self::check_required_variables(config, &mut report);
21 Self::check_empty_values(config, &mut report);
22 Self::check_url_formats(config, &mut report);
23 Self::check_port_values(config, &mut report);
24 Self::check_environment_specific(config, &mut report);
25
26 CliService::section("Validation Summary");
27
28 if report.errors.is_empty() {
29 CliService::success("Validation PASSED");
30 } else {
31 CliService::error("Validation FAILED");
32 CliService::info("Errors:");
33 for error in &report.errors {
34 CliService::info(&format!(" - {error}"));
35 }
36 }
37
38 if !report.warnings.is_empty() {
39 CliService::info("Warnings:");
40 for warning in &report.warnings {
41 CliService::info(&format!(" - {warning}"));
42 }
43 }
44
45 if report.errors.is_empty() {
46 Ok(report)
47 } else {
48 Err(anyhow!("{} validation error(s)", report.errors.len()))
49 }
50 }
51
52 fn check_unresolved_variables(config: &EnvironmentConfig, report: &mut ValidationReport) {
53 let Ok(var_regex) = Regex::new(r"\$\{[^}]+\}") else {
54 report.add_error("Internal error: Invalid unresolved variable regex".to_string());
55 return;
56 };
57 let mut unresolved = Vec::new();
58
59 for (key, value) in &config.variables {
60 if var_regex.is_match(value) {
61 unresolved.push(format!("{key} = {value}"));
62 }
63 }
64
65 if unresolved.is_empty() {
66 CliService::success("No unresolved variables found");
67 } else {
68 CliService::error("Found unresolved variables:");
69 for u in &unresolved {
70 CliService::info(&format!(" {u}"));
71 report.add_error(format!("Unresolved variable: {u}"));
72 }
73 }
74 }
75
76 fn check_required_variables(config: &EnvironmentConfig, report: &mut ValidationReport) {
77 let required_vars = vec![
78 "SERVICE_NAME",
79 "SYSTEM_PATH",
80 "DATABASE_URL",
81 "HOST",
82 "PORT",
83 "API_SERVER_URL",
84 "JWT_SECRET",
85 "JWT_ISSUER",
86 ];
87
88 let mut missing = Vec::new();
89
90 for var in &required_vars {
91 let is_missing_or_empty = config.variables.get(*var).is_none_or(String::is_empty);
92 if is_missing_or_empty {
93 missing.push(*var);
94 }
95 }
96
97 if missing.is_empty() {
98 CliService::success("All required variables present");
99 } else {
100 CliService::error("Required variables missing:");
101 for m in &missing {
102 CliService::info(&format!(" {m}"));
103 report.add_error(format!("Required variable missing: {m}"));
104 }
105 }
106 }
107
108 fn check_empty_values(config: &EnvironmentConfig, report: &mut ValidationReport) {
109 let critical_vars = vec!["DATABASE_URL", "JWT_SECRET"];
110
111 let mut empty = Vec::new();
112
113 for var in &critical_vars {
114 if let Some(value) = config.variables.get(*var) {
115 if value.is_empty() || value == "''" || value == "\"\"" {
116 empty.push(*var);
117 }
118 }
119 }
120
121 if empty.is_empty() {
122 CliService::success("All critical variables have values");
123 } else {
124 CliService::error("Critical variables are empty:");
125 for e in &empty {
126 CliService::info(&format!(" {e}"));
127 report.add_error(format!("Critical variable is empty: {e}"));
128 }
129 }
130 }
131
132 fn check_url_formats(config: &EnvironmentConfig, report: &mut ValidationReport) {
133 let url_vars = vec!["DATABASE_URL", "API_SERVER_URL", "API_EXTERNAL_URL"];
134
135 let Ok(url_regex) = Regex::new(r"^(https?|postgresql|mysql)://.*$") else {
136 report.add_error("Internal error: Invalid URL regex".to_string());
137 return;
138 };
139
140 let mut invalid = Vec::new();
141
142 for url_var in &url_vars {
143 if let Some(url) = config.variables.get(*url_var) {
144 if !url.is_empty() && !url_regex.is_match(url) {
145 invalid.push(format!("{url_var} = {url}"));
146 }
147 }
148 }
149
150 if invalid.is_empty() {
151 CliService::success("All URL formats are valid");
152 } else {
153 CliService::error("Invalid URL formats:");
154 for i in &invalid {
155 CliService::info(&format!(" {i}"));
156 report.add_error(format!("Invalid URL format: {i}"));
157 }
158 }
159 }
160
161 fn check_port_values(config: &EnvironmentConfig, report: &mut ValidationReport) {
162 if let Some(port_str) = config.variables.get("PORT") {
163 if let Ok(port) = port_str.parse::<u16>() {
164 if port == 0 {
165 CliService::error(&format!("Invalid port number: {} (must be 1-65535)", port));
166 report.add_error(format!("Invalid port number: {port}"));
167 } else {
168 CliService::success(&format!("Port number is valid: {port}"));
169 }
170 } else {
171 CliService::error(&format!("Port is not a valid number: {port_str}"));
172 report.add_error(format!("Port is not a valid number: {port_str}"));
173 }
174 } else {
175 CliService::warning("PORT not explicitly set, will use default");
176 report.add_warning("PORT not explicitly set".to_string());
177 }
178 }
179
180 fn check_environment_specific(config: &EnvironmentConfig, report: &mut ValidationReport) {
181 match config.environment {
182 DeployEnvironment::Production => {
183 if let Some(use_https) = config.variables.get("USE_HTTPS") {
184 if use_https != "true" {
185 CliService::warning("Production environment should have USE_HTTPS=true");
186 report.add_warning("Production should have USE_HTTPS=true".to_string());
187 }
188 }
189
190 if let Some(rust_log) = config.variables.get("RUST_LOG") {
191 if rust_log == "debug" {
192 CliService::warning(
193 "Production environment should not have RUST_LOG=debug",
194 );
195 report.add_warning("Production should not have RUST_LOG=debug".to_string());
196 }
197 }
198
199 CliService::success(&format!(
200 "Environment-specific checks passed for: {}",
201 config.environment.as_str()
202 ));
203 },
204 _ => {
205 CliService::success(&format!(
206 "Environment-specific checks passed for: {}",
207 config.environment.as_str()
208 ));
209 },
210 }
211 }
212
213 pub fn check_file_permissions(path: &Path, report: &mut ValidationReport) -> Result<()> {
214 if !path.exists() {
215 return Ok(());
216 }
217
218 #[cfg(unix)]
219 {
220 use std::os::unix::fs::PermissionsExt;
221 let metadata = std::fs::metadata(path)?;
222 let permissions = metadata.permissions();
223 let mode = permissions.mode();
224 let perms_octal = format!("{:o}", mode & 0o777);
225
226 if perms_octal == "644" || perms_octal == "600" {
227 CliService::success(".env file has appropriate permissions");
228 } else {
229 CliService::warning(&format!(
230 ".env file permissions may expose secrets: {}",
231 perms_octal
232 ));
233 report.add_warning(format!(
234 ".env file permissions may expose secrets: {}",
235 perms_octal
236 ));
237 }
238 }
239
240 #[cfg(not(unix))]
241 {
242 CliService::warning("File permission check skipped (non-Unix system)");
243 }
244
245 Ok(())
246 }
247}
248
249#[derive(Debug)]
250pub struct ValidationReport {
251 pub errors: Vec<String>,
252 pub warnings: Vec<String>,
253}
254
255impl ValidationReport {
256 pub const fn new() -> Self {
257 Self {
258 errors: Vec::new(),
259 warnings: Vec::new(),
260 }
261 }
262
263 pub fn add_error(&mut self, error: String) {
264 self.errors.push(error);
265 }
266
267 pub fn add_warning(&mut self, warning: String) {
268 self.warnings.push(warning);
269 }
270
271 pub fn is_valid(&self) -> bool {
272 self.errors.is_empty()
273 }
274}
275
276impl Default for ValidationReport {
277 fn default() -> Self {
278 Self::new()
279 }
280}