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