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