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