Skip to main content

systemprompt_models/profile/validation/
mod.rs

1//! Profile validation logic.
2//!
3//! This module contains all validation logic for Profile configurations,
4//! including path validation, security settings, CORS, and rate limits.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9mod 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}