1use std::fmt;
2
3use ipnet::IpNet;
4use thiserror::Error;
5
6use super::*;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ConfigValidationErrors(pub Vec<ConfigValidationError>);
10
11impl fmt::Display for ConfigValidationErrors {
12 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13 let joined = self
14 .0
15 .iter()
16 .map(ToString::to_string)
17 .collect::<Vec<_>>()
18 .join("; ");
19 f.write_str(&joined)
20 }
21}
22
23impl std::error::Error for ConfigValidationErrors {}
24
25#[derive(Debug, Clone, Error, PartialEq, Eq)]
26pub enum ConfigValidationError {
27 #[error("app.name must not be empty")]
28 EmptyAppName,
29 #[error("server.bind must not be empty")]
30 EmptyServerBind,
31 #[error("server.trusted_proxies contains invalid entry `{value}`")]
32 InvalidTrustedProxy { value: String },
33 #[error("{field} must be greater than zero")]
34 InvalidSessionTimeout { field: &'static str },
35 #[error(
36 "http.session.absolute_timeout_secs ({absolute_timeout_secs}) must be at least idle_timeout_secs ({idle_timeout_secs})"
37 )]
38 AbsoluteSessionTimeoutTooShort {
39 idle_timeout_secs: u64,
40 absolute_timeout_secs: u64,
41 },
42 #[error("{cookie}.name must not be empty")]
43 EmptyCookieName { cookie: &'static str },
44 #[error("{cookie}.path must start with `/`, got `{path}`")]
45 InvalidCookiePath { cookie: &'static str, path: String },
46 #[error("{cookie} must be secure when same_site=none")]
47 SameSiteNoneRequiresSecure { cookie: &'static str },
48 #[error("http.csrf.field_name must not be empty when CSRF is enabled")]
49 EmptyCsrfFieldName,
50 #[error("http.csrf.header_name must not be empty when CSRF is enabled")]
51 EmptyCsrfHeaderName,
52 #[error("at least one supported locale must be configured")]
53 MissingSupportedLocales,
54 #[error("default locale `{default_locale}` is not in supported_locales {supported_locales:?}")]
55 DefaultLocaleNotSupported {
56 default_locale: String,
57 supported_locales: Vec<String>,
58 },
59 #[error(
60 "fallback locale `{fallback_locale}` is not in supported_locales {supported_locales:?}"
61 )]
62 FallbackLocaleNotSupported {
63 fallback_locale: String,
64 supported_locales: Vec<String>,
65 },
66 #[error("seo.canonical_host must not be empty")]
67 EmptyCanonicalHost,
68 #[error("site `{site}` display_name must not be empty")]
69 EmptySiteDisplayName { site: String },
70 #[error("site `{site}` canonical_host must not be empty")]
71 EmptySiteCanonicalHost { site: String },
72 #[error("site `{site}` hosts must not be empty")]
73 MissingSiteHosts { site: String },
74 #[error("site `{site}` is declared more than once")]
75 DuplicateSite { site: String },
76 #[error("site host `{host}` is declared more than once")]
77 DuplicateSiteHost { host: String },
78 #[error(
79 "site `{site}` default locale `{default_locale}` is not in supported_locales {supported_locales:?}"
80 )]
81 SiteDefaultLocaleNotSupported {
82 site: String,
83 default_locale: String,
84 supported_locales: Vec<String>,
85 },
86 #[error(
87 "site `{site}` locale `{locale}` is not in app supported_locales {supported_locales:?}"
88 )]
89 SiteLocaleOutsideAppSupport {
90 site: String,
91 locale: String,
92 supported_locales: Vec<String>,
93 },
94 #[error("auth.package must not be empty")]
95 EmptyAuthPackage,
96 #[error("auth.tenant_id must be greater than zero, got {tenant_id}")]
97 InvalidAuthTenantId { tenant_id: i64 },
98 #[error("wasm.default_time_limit_ms must be greater than zero")]
99 InvalidWasmTimeLimit,
100 #[error("wasm.outbound_http contains an entry with an empty integration name")]
101 EmptyWasmOutboundHttpIntegration,
102 #[error("wasm.outbound_http contains duplicate integration `{integration}`")]
103 DuplicateWasmOutboundHttpIntegration { integration: String },
104 #[error(
105 "wasm.outbound_http integration `{integration}` must use http or https, got `{scheme}`"
106 )]
107 InvalidWasmOutboundHttpScheme { integration: String, scheme: String },
108 #[error("wasm.outbound_http integration `{integration}` must include a host")]
109 MissingWasmOutboundHttpHost { integration: String },
110 #[error("wasm.outbound_http integration `{integration}` must not include embedded credentials")]
111 WasmOutboundHttpHasCredentials { integration: String },
112 #[error("storage.local_root must not be empty")]
113 EmptyLocalStorageRoot,
114 #[error(
115 "storage.default_class={storage_class:?} requires storage.single_node_escape_hatch=explicit_single_node because local-only storage must be explicitly enabled"
116 )]
117 LocalOnlyStorageRequiresExplicitOptIn { storage_class: StorageClass },
118 #[error(
119 "storage.single_node_escape_hatch=explicit_single_node requires storage.deployment=single_node because local-only storage is not permitted in distributed deployments"
120 )]
121 LocalOnlyStorageRequiresSingleNodeDeployment,
122 #[error("database.schema must not be empty")]
123 EmptyDatabaseSchema,
124 #[error("database.migrations_table must not be empty")]
125 EmptyMigrationsTable,
126 #[error(
127 "database pool sizing is invalid: min_connections={min_connections} max_connections={max_connections}"
128 )]
129 InvalidDatabasePoolSize {
130 min_connections: u16,
131 max_connections: u16,
132 },
133 #[error("assets.publish_manifest requires assets.cdn_base_url")]
134 MissingCdnBaseUrl,
135 #[error("assets.cdn_base_url must start with http:// or https://, got `{url}`")]
136 InvalidCdnBaseUrl { url: String },
137 #[error("at least one module must be enabled")]
138 NoModulesEnabled,
139 #[error("tls.challenge is required when tls.mode=acme")]
140 MissingTlsChallenge,
141 #[error("tls.challenge is not valid when tls.mode={mode:?}")]
142 TlsChallengeNotAllowed { mode: TlsMode },
143 #[error("dns-01 ACME requires a DNS automation provider")]
144 MissingDnsAutomationProvider,
145 #[error("tls.mode={mode:?} cannot be used with provider {provider:?}")]
146 IncompatibleTlsProvider {
147 mode: TlsMode,
148 provider: TlsProvider,
149 },
150 #[error("tls.mode=cloudflare-origin requires provider=cloudflare-origin-ca")]
151 CloudflareOriginRequiresOriginCa,
152 #[error("tls.mode=manual requires provider=manual-import")]
153 ManualTlsRequiresManualProvider,
154 #[error(
155 "http.session.store={store:?} requires cache.l2={store:?} semantics, got {cache_backend:?}"
156 )]
157 SessionStoreRequiresDistributedCache {
158 store: SessionStore,
159 cache_backend: Option<DistributedCache>,
160 },
161}
162
163impl PlatformConfig {
164 pub fn validate(&self) -> Result<(), ConfigValidationErrors> {
165 let mut errors = Vec::new();
166
167 if self.app.name.trim().is_empty() {
168 errors.push(ConfigValidationError::EmptyAppName);
169 }
170
171 if self.server.bind.trim().is_empty() {
172 errors.push(ConfigValidationError::EmptyServerBind);
173 }
174
175 for trusted_proxy in &self.server.trusted_proxies {
176 if trusted_proxy.parse::<IpNet>().is_err() {
177 errors.push(ConfigValidationError::InvalidTrustedProxy {
178 value: trusted_proxy.clone(),
179 });
180 }
181 }
182
183 if self.http.session.idle_timeout_secs == 0 {
184 errors.push(ConfigValidationError::InvalidSessionTimeout {
185 field: "http.session.idle_timeout_secs",
186 });
187 }
188
189 if self.http.session.absolute_timeout_secs == 0 {
190 errors.push(ConfigValidationError::InvalidSessionTimeout {
191 field: "http.session.absolute_timeout_secs",
192 });
193 }
194
195 if self.http.session.absolute_timeout_secs < self.http.session.idle_timeout_secs {
196 errors.push(ConfigValidationError::AbsoluteSessionTimeoutTooShort {
197 idle_timeout_secs: self.http.session.idle_timeout_secs,
198 absolute_timeout_secs: self.http.session.absolute_timeout_secs,
199 });
200 }
201
202 for (cookie_name, cookie) in [
203 ("http.session_cookie", &self.http.session_cookie),
204 ("http.flash_cookie", &self.http.flash_cookie),
205 ] {
206 if cookie.name.trim().is_empty() {
207 errors.push(ConfigValidationError::EmptyCookieName {
208 cookie: cookie_name,
209 });
210 }
211
212 if cookie.path.trim().is_empty() || !cookie.path.starts_with('/') {
213 errors.push(ConfigValidationError::InvalidCookiePath {
214 cookie: cookie_name,
215 path: cookie.path.clone(),
216 });
217 }
218
219 if cookie.same_site == SameSitePolicy::None && !cookie.secure {
220 errors.push(ConfigValidationError::SameSiteNoneRequiresSecure {
221 cookie: cookie_name,
222 });
223 }
224 }
225
226 if self.http.csrf.enabled {
227 if self.http.csrf.field_name.trim().is_empty() {
228 errors.push(ConfigValidationError::EmptyCsrfFieldName);
229 }
230
231 if self.http.csrf.header_name.trim().is_empty() {
232 errors.push(ConfigValidationError::EmptyCsrfHeaderName);
233 }
234 }
235
236 let has_explicit_sites = !self.sites.is_empty();
237 if self.i18n.supported_locales.is_empty() {
238 if !has_explicit_sites {
239 errors.push(ConfigValidationError::MissingSupportedLocales);
240 }
241 } else {
242 if self.i18n.default_locale.trim().is_empty()
243 || !self
244 .i18n
245 .supported_locales
246 .contains(&self.i18n.default_locale)
247 {
248 errors.push(ConfigValidationError::DefaultLocaleNotSupported {
249 default_locale: self.i18n.default_locale.clone(),
250 supported_locales: self.i18n.supported_locales.clone(),
251 });
252 }
253
254 if self.i18n.fallback_locale.trim().is_empty()
255 || !self
256 .i18n
257 .supported_locales
258 .contains(&self.i18n.fallback_locale)
259 {
260 errors.push(ConfigValidationError::FallbackLocaleNotSupported {
261 fallback_locale: self.i18n.fallback_locale.clone(),
262 supported_locales: self.i18n.supported_locales.clone(),
263 });
264 }
265 }
266
267 if self.seo.canonical_host.trim().is_empty() && !has_explicit_sites {
268 errors.push(ConfigValidationError::EmptyCanonicalHost);
269 }
270
271 let mut site_ids = std::collections::BTreeSet::new();
272 let mut site_hosts = std::collections::BTreeSet::new();
273 for site in &self.sites {
274 if site.id.trim().is_empty() {
275 errors.push(ConfigValidationError::DuplicateSite {
276 site: site.id.clone(),
277 });
278 } else if !site_ids.insert(site.id.clone()) {
279 errors.push(ConfigValidationError::DuplicateSite {
280 site: site.id.clone(),
281 });
282 }
283
284 if site.display_name.trim().is_empty() {
285 errors.push(ConfigValidationError::EmptySiteDisplayName {
286 site: site.id.clone(),
287 });
288 }
289
290 if site.canonical_host.trim().is_empty() {
291 errors.push(ConfigValidationError::EmptySiteCanonicalHost {
292 site: site.id.clone(),
293 });
294 }
295
296 if site.hosts.is_empty() {
297 errors.push(ConfigValidationError::MissingSiteHosts {
298 site: site.id.clone(),
299 });
300 }
301
302 if !site.supported_locales.contains(&site.default_locale) {
303 errors.push(ConfigValidationError::SiteDefaultLocaleNotSupported {
304 site: site.id.clone(),
305 default_locale: site.default_locale.clone(),
306 supported_locales: site.supported_locales.clone(),
307 });
308 }
309
310 for locale in &site.supported_locales {
311 if !self.i18n.supported_locales.is_empty()
312 && !self.i18n.supported_locales.contains(locale)
313 {
314 errors.push(ConfigValidationError::SiteLocaleOutsideAppSupport {
315 site: site.id.clone(),
316 locale: locale.clone(),
317 supported_locales: self.i18n.supported_locales.clone(),
318 });
319 }
320 }
321
322 for host in site
323 .hosts
324 .iter()
325 .cloned()
326 .chain(std::iter::once(site.canonical_host.clone()))
327 {
328 if !site_hosts.insert(host.clone()) {
329 errors.push(ConfigValidationError::DuplicateSiteHost { host });
330 }
331 }
332 }
333
334 if self.auth.package.trim().is_empty() {
335 errors.push(ConfigValidationError::EmptyAuthPackage);
336 }
337
338 if self.auth.tenant_id <= 0 {
339 errors.push(ConfigValidationError::InvalidAuthTenantId {
340 tenant_id: self.auth.tenant_id,
341 });
342 }
343
344 if self.wasm.default_time_limit_ms == 0 {
345 errors.push(ConfigValidationError::InvalidWasmTimeLimit);
346 }
347
348 let mut outbound_http_integrations = std::collections::BTreeSet::new();
349 for integration in &self.wasm.outbound_http {
350 if integration.integration.trim().is_empty() {
351 errors.push(ConfigValidationError::EmptyWasmOutboundHttpIntegration);
352 continue;
353 }
354
355 if !outbound_http_integrations.insert(integration.integration.clone()) {
356 errors.push(
357 ConfigValidationError::DuplicateWasmOutboundHttpIntegration {
358 integration: integration.integration.clone(),
359 },
360 );
361 }
362
363 match integration.endpoint.scheme() {
364 "http" | "https" => {}
365 scheme => {
366 errors.push(ConfigValidationError::InvalidWasmOutboundHttpScheme {
367 integration: integration.integration.clone(),
368 scheme: scheme.to_string(),
369 });
370 }
371 }
372
373 if integration.endpoint.host_str().is_none() {
374 errors.push(ConfigValidationError::MissingWasmOutboundHttpHost {
375 integration: integration.integration.clone(),
376 });
377 }
378
379 if !integration.endpoint.username().is_empty()
380 || integration.endpoint.password().is_some()
381 {
382 errors.push(ConfigValidationError::WasmOutboundHttpHasCredentials {
383 integration: integration.integration.clone(),
384 });
385 }
386 }
387
388 if self.storage.local_root.trim().is_empty() {
389 errors.push(ConfigValidationError::EmptyLocalStorageRoot);
390 }
391
392 if matches!(self.storage.default_class, StorageClass::LocalOnlySensitive)
393 && self.storage.single_node_escape_hatch != SingleNodeStorageMode::ExplicitSingleNode
394 {
395 errors.push(
396 ConfigValidationError::LocalOnlyStorageRequiresExplicitOptIn {
397 storage_class: self.storage.default_class,
398 },
399 );
400 }
401
402 if self.storage.single_node_escape_hatch == SingleNodeStorageMode::ExplicitSingleNode
403 && self.storage.deployment != StorageDeployment::SingleNode
404 {
405 errors.push(ConfigValidationError::LocalOnlyStorageRequiresSingleNodeDeployment);
406 }
407
408 if self.database.schema.trim().is_empty() {
409 errors.push(ConfigValidationError::EmptyDatabaseSchema);
410 }
411
412 if self.database.migrations_table.trim().is_empty() {
413 errors.push(ConfigValidationError::EmptyMigrationsTable);
414 }
415
416 if self.database.max_connections == 0
417 || self.database.min_connections > self.database.max_connections
418 {
419 errors.push(ConfigValidationError::InvalidDatabasePoolSize {
420 min_connections: self.database.min_connections,
421 max_connections: self.database.max_connections,
422 });
423 }
424
425 if self.assets.publish_manifest {
426 match self.assets.cdn_base_url.as_deref() {
427 Some(url) if url.starts_with("https://") || url.starts_with("http://") => {}
428 Some(url) => errors.push(ConfigValidationError::InvalidCdnBaseUrl {
429 url: url.to_string(),
430 }),
431 None => errors.push(ConfigValidationError::MissingCdnBaseUrl),
432 }
433 }
434
435 if self.modules.enabled.is_empty() {
436 errors.push(ConfigValidationError::NoModulesEnabled);
437 }
438
439 match self.http.session.store {
440 SessionStore::Redis => {
441 if self.cache.l2 != Some(DistributedCache::Redis) {
442 errors.push(
443 ConfigValidationError::SessionStoreRequiresDistributedCache {
444 store: self.http.session.store,
445 cache_backend: self.cache.l2,
446 },
447 );
448 }
449 }
450 SessionStore::Valkey => {
451 if self.cache.l2 != Some(DistributedCache::Valkey) {
452 errors.push(
453 ConfigValidationError::SessionStoreRequiresDistributedCache {
454 store: self.http.session.store,
455 cache_backend: self.cache.l2,
456 },
457 );
458 }
459 }
460 SessionStore::Memory | SessionStore::Database => {}
461 }
462
463 match self.tls.mode {
464 TlsMode::External => {
465 if self.tls.challenge.is_some() {
466 errors.push(ConfigValidationError::TlsChallengeNotAllowed {
467 mode: self.tls.mode,
468 });
469 }
470 }
471 TlsMode::Acme => {
472 if self.tls.challenge.is_none() {
473 errors.push(ConfigValidationError::MissingTlsChallenge);
474 }
475
476 if self.tls.provider == Some(TlsProvider::CloudflareOriginCa) {
477 errors.push(ConfigValidationError::IncompatibleTlsProvider {
478 mode: self.tls.mode,
479 provider: TlsProvider::CloudflareOriginCa,
480 });
481 }
482
483 if self.tls.challenge == Some(AcmeChallenge::Dns01) && self.tls.provider.is_none() {
484 errors.push(ConfigValidationError::MissingDnsAutomationProvider);
485 }
486 }
487 TlsMode::CloudflareOrigin => {
488 if self.tls.provider != Some(TlsProvider::CloudflareOriginCa) {
489 errors.push(ConfigValidationError::CloudflareOriginRequiresOriginCa);
490 }
491 }
492 TlsMode::Manual => {
493 if self.tls.provider != Some(TlsProvider::ManualImport) {
494 errors.push(ConfigValidationError::ManualTlsRequiresManualProvider);
495 }
496 }
497 }
498
499 if errors.is_empty() {
500 Ok(())
501 } else {
502 Err(ConfigValidationErrors(errors))
503 }
504 }
505}