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;
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}