Skip to main content

systemprompt_config/services/
validator.rs

1use 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}