Skip to main content

coil_config/
validation.rs

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}