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