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