systemprompt_models/profile/validation/
mod.rs1mod network;
10mod security;
11mod services;
12
13use super::{Profile, ProfileError, ProfileResult};
14
15impl Profile {
16 pub fn validate(&self) -> ProfileResult<()> {
17 let mut errors: Vec<String> = Vec::new();
18 let is_cloud = self.target.is_cloud();
19
20 self.validate_required_fields(&mut errors);
21 self.validate_urls(&mut errors);
22 self.validate_paths(&mut errors, is_cloud);
23 self.validate_security_settings(&mut errors);
24 self.validate_database_pool(&mut errors);
25 self.validate_trusted_proxies(&mut errors, is_cloud);
26 self.validate_cors_origins(&mut errors);
27 self.validate_rate_limits(&mut errors);
28 self.validate_governance(&mut errors, is_cloud);
29 self.validate_storage(&mut errors);
30 self.validate_evaluator(&mut errors);
31 self.validate_external_url_is_reachable(&mut errors, is_cloud);
32 self.validate_services_sources(&mut errors, is_cloud);
33 self.validate_secrets(&mut errors);
34
35 if errors.is_empty() {
36 Ok(())
37 } else {
38 Err(ProfileError::Validation {
39 name: self.name.clone(),
40 errors,
41 })
42 }
43 }
44
45 pub(crate) fn validate_storage(&self, errors: &mut Vec<String>) {
46 match self.storage.backend {
47 super::StorageBackend::Local => {
48 if self.paths.storage.as_deref().is_none_or(str::is_empty) {
49 errors.push(
50 "storage.backend 'local' requires paths.storage to be set".to_owned(),
51 );
52 }
53 },
54 }
55 }
56
57 pub(crate) fn validate_evaluator(&self, errors: &mut Vec<String>) {
58 let Some(evaluator) = &self.evaluator else {
59 return;
60 };
61 for (field, value) in [
62 ("evaluator.worker_id", evaluator.worker_id.as_str()),
63 ("evaluator.client_image", evaluator.client_image.as_str()),
64 ("evaluator.relay_image", evaluator.relay_image.as_str()),
65 (
66 "evaluator.control_network",
67 evaluator.control_network.as_str(),
68 ),
69 ] {
70 if value.trim().is_empty() {
71 errors.push(format!("{field} must not be empty"));
72 }
73 }
74 if !evaluator.docker.is_absolute() {
75 errors.push("evaluator.docker must be an absolute path".to_owned());
76 }
77 if !evaluator.workspace_root.is_absolute() {
78 errors.push("evaluator.workspace_root must be an absolute path".to_owned());
79 }
80 }
81
82 pub(crate) fn validate_paths(&self, errors: &mut Vec<String>, is_cloud: bool) {
83 if is_cloud {
84 self.validate_cloud_paths(errors);
85 } else {
86 self.validate_local_paths(errors);
87 }
88 }
89
90 pub(crate) fn validate_cloud_paths(&self, errors: &mut Vec<String>) {
91 Self::require_non_empty(errors, &self.paths.system, "Paths system");
92 Self::require_non_empty(errors, &self.paths.services, "Paths services");
93 Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
94
95 for (name, path) in [
96 ("system", self.paths.system.as_str()),
97 ("services", self.paths.services.as_str()),
98 ("bin", self.paths.bin.as_str()),
99 ] {
100 if !path.is_empty() && !path.starts_with("/app") {
101 errors.push(format!(
102 "Cloud profile {} path should start with /app, got: {}",
103 name, path
104 ));
105 }
106 }
107
108 if let Some(web_path) = &self.paths.web_path
109 && !web_path.is_empty()
110 {
111 if !web_path.starts_with("/app/web") {
112 errors.push(format!(
113 "Cloud profile web_path should start with /app/web, got: {}. Note: \
114 web_path points to the parent of dist/, e.g., /app/web for /app/web/dist",
115 web_path
116 ));
117 }
118 if web_path.contains("/services/web") {
119 errors.push(format!(
120 "Cloud profile web_path should be /app/web (for dist output), not \
121 /app/services/web (which is for templates/config). Got: {}",
122 web_path
123 ));
124 }
125 }
126 }
127
128 pub(crate) fn validate_local_paths(&self, errors: &mut Vec<String>) {
129 Self::require_non_empty(errors, &self.paths.system, "Paths system");
130 Self::require_non_empty(errors, &self.paths.services, "Paths services");
131 Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
132 }
133
134 pub(crate) fn validate_required_fields(&self, errors: &mut Vec<String>) {
135 Self::require_non_empty(errors, &self.name, "Profile name");
136 Self::require_non_empty(errors, &self.display_name, "Profile display_name");
137 Self::require_non_empty(errors, &self.site.name, "Site name");
138 Self::require_non_empty(errors, &self.server.host, "Server host");
139 Self::require_non_empty(errors, &self.server.api_server_url, "Server api_server_url");
140 Self::require_non_empty(
141 errors,
142 &self.server.api_internal_url,
143 "Server api_internal_url",
144 );
145 Self::require_non_empty(
146 errors,
147 &self.server.api_external_url,
148 "Server api_external_url",
149 );
150
151 if self.server.port == 0 {
152 errors.push("Server port must be greater than 0".to_owned());
153 }
154
155 if let Some(metrics_port) = self.server.metrics_port {
156 if metrics_port == 0 {
157 errors.push("server.metrics_port must be greater than 0".to_owned());
158 } else if metrics_port == self.server.port {
159 errors.push(format!(
160 "server.metrics_port must differ from server.port (both are {metrics_port})"
161 ));
162 }
163 }
164 }
165
166 pub(crate) fn require_non_empty(errors: &mut Vec<String>, value: &str, field_name: &str) {
167 if value.is_empty() {
168 errors.push(format!("{field_name} is required"));
169 }
170 }
171
172 pub(crate) fn validate_urls(&self, errors: &mut Vec<String>) {
173 for (name, value) in [
174 ("server.api_server_url", self.server.api_server_url.as_str()),
175 (
176 "server.api_internal_url",
177 self.server.api_internal_url.as_str(),
178 ),
179 (
180 "server.api_external_url",
181 self.server.api_external_url.as_str(),
182 ),
183 ("security.issuer", self.security.issuer.as_str()),
184 ] {
185 Self::require_absolute_url(errors, name, value, false);
186 }
187
188 if !self.server.host.is_empty() && self.server.host.contains("://") {
189 errors.push(format!(
190 "server.host must be a bare hostname or IP, not a URL (got: {})",
191 self.server.host
192 ));
193 }
194
195 for (idx, issuer) in self.security.trusted_issuers.iter().enumerate() {
196 Self::require_absolute_url(
197 errors,
198 &format!("security.trusted_issuers[{idx}].issuer"),
199 &issuer.issuer,
200 false,
201 );
202 Self::require_absolute_url(
203 errors,
204 &format!("security.trusted_issuers[{idx}].jwks_uri"),
205 &issuer.jwks_uri,
206 true,
207 );
208 }
209
210 if let Some(hook) = self.governance.as_ref().and_then(|g| g.authz.as_ref())
211 && let Some(url) = hook.hook.url.as_deref()
212 {
213 Self::require_absolute_url(errors, "governance.authz.hook.url", url, false);
214 }
215 }
216
217 fn require_absolute_url(errors: &mut Vec<String>, field: &str, value: &str, https_only: bool) {
218 if value.is_empty() {
219 return;
220 }
221 let allowed: &[&str] = if https_only {
222 &["https"]
223 } else {
224 &["http", "https"]
225 };
226 match url::Url::parse(value) {
227 Ok(url) if !allowed.contains(&url.scheme()) => {
228 errors.push(format!(
229 "{field} must be {} (got scheme '{}': {value})",
230 if https_only {
231 "an https URL"
232 } else {
233 "an http(s) URL"
234 },
235 url.scheme()
236 ));
237 },
238 Ok(url) if url.host_str().is_none_or(str::is_empty) => {
239 errors.push(format!("{field} must include a host (got: {value})"));
240 },
241 Ok(_) => {},
242 Err(e) => errors.push(format!("{field} is not a valid URL ({e}): {value}")),
243 }
244 }
245}