umbral_core/check.rs
1//! The boot-time system check framework.
2//!
3//! The `App::builder().build()` lifecycle runs the system check as
4//! phase 4 (per spec 01 §Lifecycle phases). The framework's built-in
5//! checks live here; plugin-contributed checks land at M7 via
6//! `Plugin::system_checks()`.
7//!
8//! At M4 the only check that's meaningful without a model registry or
9//! Plugin walk is [`settings_required`] — it verifies that production
10//! `Settings` have safe values (most importantly that `secret_key`
11//! isn't left at the insecure dev default). More checks (`field.
12//! backend`, `model.pk.present`, `model.table.unique`, `route.
13//! collision`, `plugin.dependency.*`) land alongside the registries
14//! they need: M5's migration engine for the model walk, M7's Plugin
15//! contract for plugin/route walks.
16//!
17//! See `docs/specs/05-backends-and-system-check.md` for the full
18//! built-in catalogue.
19
20use crate::backend::DatabaseBackend;
21use crate::settings::{Environment, Settings};
22
23/// The insecure dev default for `Settings.secret_key`. Kept in sync with
24/// `crate::settings::default_secret_key()`; that function returns an owned
25/// `String`, so duplicating the literal here lets the check compare without
26/// allocating.
27const INSECURE_DEV_SECRET_KEY: &str = "umbral-insecure-dev-key-change-me";
28
29/// The default `allowed_hosts` list emitted by
30/// `crate::settings::default_allowed_hosts()`. Mirrored here so the
31/// `settings.allowed_hosts` check can detect "still the dev default"
32/// without allocating.
33const DEFAULT_ALLOWED_HOSTS: &[&str] = &["localhost", "127.0.0.1"];
34
35/// One named system check.
36///
37/// Built-in checks live in `framework_checks()`; plugin checks return
38/// from `Plugin::system_checks()` (M7). Each check is a function pointer
39/// that takes the [`CheckContext`] and produces zero or more
40/// [`SystemCheckFinding`]s.
41pub struct SystemCheck {
42 /// Stable identifier, dot-delimited. Used in error reports and so
43 /// users can grep for failures: `field.backend`, `settings.required`,
44 /// etc.
45 pub id: &'static str,
46 /// The check function.
47 pub run: fn(&CheckContext<'_>) -> Vec<SystemCheckFinding>,
48}
49
50/// Context available to a system check at boot.
51///
52/// Holds references to everything a check might consult: the active
53/// backend, the validated settings. The model list (M5) and plugin
54/// registry (M7) get added when they exist.
55pub struct CheckContext<'a> {
56 /// The active database backend.
57 pub backend: &'a dyn DatabaseBackend,
58 /// The runtime settings, post-load, pre-publish.
59 pub settings: &'a Settings,
60 /// `true` when at least one registered plugin reports
61 /// [`crate::plugin::Plugin::provides_storage`]. The
62 /// `field.storage_backend` check reads this to decide whether a
63 /// model with a `FileField` / `ImageField` has a backend to resolve
64 /// uploads through.
65 ///
66 /// This is the *capability flag* of the plugin list, not the ambient
67 /// `crate::storage::storage_opt()` — storage is registered in
68 /// `on_ready`, which runs *after* this check, so the ambient backend
69 /// isn't published yet at check time. `App::build` populates this
70 /// from the sorted plugin list before running the checks. Tests that
71 /// build a `CheckContext` by hand (without a plugin walk) set `true`
72 /// to keep the storage check inert.
73 pub provides_storage: bool,
74 /// The names of every registered plugin, in topological order, as
75 /// returned by [`crate::plugin::Plugin::name`]. Populated by
76 /// `App::build` before running phase 4 checks. Tests that build a
77 /// `CheckContext` by hand should supply an empty slice (`&[]`) to
78 /// make plugin-aware checks that need a specific set of names inert,
79 /// or supply the names they want to exercise directly.
80 pub registered_plugin_names: &'a [&'a str],
81}
82
83/// One issue surfaced by a system check.
84#[derive(Debug)]
85pub struct SystemCheckFinding {
86 /// The id of the check that produced this finding. Matches the
87 /// owning [`SystemCheck::id`].
88 pub check_id: &'static str,
89 /// Whether this is an error (blocks boot) or just a warning (logged
90 /// and proceeds).
91 pub severity: Severity,
92 /// The thing that's broken: which model, which field, which plugin,
93 /// which route, or just "the settings."
94 pub location: CheckLocation,
95 /// A user-facing one-line message.
96 pub message: String,
97 /// Optional follow-up: what the user should change to fix it.
98 pub hint: Option<String>,
99}
100
101/// Severity of a system-check finding.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Severity {
104 /// Block boot. `AppBuilder::build()` returns
105 /// `BuildError::SystemCheckFailed`.
106 Error,
107 /// Log via `tracing::warn!`, continue booting.
108 Warning,
109}
110
111/// Where in the framework a finding originates. The variants grow as
112/// the registries do.
113#[derive(Debug, Clone)]
114pub enum CheckLocation {
115 /// A field on a model. M5/M7 work.
116 Field {
117 plugin: &'static str,
118 model: &'static str,
119 field: &'static str,
120 },
121 /// A model. M5/M7 work.
122 Model {
123 plugin: &'static str,
124 model: &'static str,
125 },
126 /// A plugin's own metadata. M7 work.
127 Plugin { plugin: &'static str },
128 /// A registered route. M7 work.
129 Route { path: String },
130 /// The settings as a whole.
131 Settings,
132}
133
134/// Return the framework's built-in checks.
135///
136/// At M4 the catalogue is intentionally short: there's no model
137/// registry (M5) or plugin walk (M7) yet, so only checks that read
138/// purely from `Settings` and the active backend are meaningful. The
139/// rest of the built-in catalogue (`field.backend`, `model.pk.present`,
140/// `model.table.unique`, `route.collision`, `plugin.dependency.*`)
141/// lands alongside the registries it needs.
142pub fn framework_checks() -> Vec<SystemCheck> {
143 vec![
144 SystemCheck {
145 id: "settings.required",
146 run: settings_required,
147 },
148 SystemCheck {
149 id: "settings.allowed_hosts",
150 run: settings_allowed_hosts,
151 },
152 SystemCheck {
153 id: "settings.host_validation",
154 run: settings_host_validation,
155 },
156 SystemCheck {
157 id: "settings.log_level",
158 run: settings_log_level,
159 },
160 SystemCheck {
161 id: "backend.url_scheme.matches_active_backend",
162 run: backend_url_scheme_matches_active_backend,
163 },
164 SystemCheck {
165 id: "field.backend",
166 run: field_backend,
167 },
168 SystemCheck {
169 id: "field.storage_backend",
170 run: field_storage_backend,
171 },
172 SystemCheck {
173 id: "field.choices_default",
174 run: field_choices_default,
175 },
176 SystemCheck {
177 id: "plugin.security_missing",
178 run: plugin_security_missing,
179 },
180 ]
181}
182
183/// Verify that `secret_key` is not the insecure dev default. Two
184/// layers:
185///
186/// 1. **Hard error in `Environment::Prod`** — the original check.
187/// Blocks boot when the operator self-identifies as production.
188/// 2. **Warning when the bind address looks public** — defense in
189/// depth for the operator who forgot to set
190/// `UMBRAL_ENVIRONMENT=Prod`. If `bind_addr` isn't `127.0.0.1` or
191/// `localhost`, the process is likely serving real network
192/// traffic, and the insecure dev key is dangerous regardless of
193/// the declared environment.
194///
195/// The boot-blocking error is intentionally reserved for explicit
196/// production declarations — surprising people with a build failure
197/// because they bound to `0.0.0.0` in a homelab test would be worse
198/// than the warning. The warning is the visible nudge.
199fn settings_required(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
200 let mut findings = Vec::new();
201 let insecure = ctx.settings.secret_key == INSECURE_DEV_SECRET_KEY;
202 if matches!(ctx.settings.environment, Environment::Prod) && insecure {
203 findings.push(SystemCheckFinding {
204 check_id: "settings.required",
205 severity: Severity::Error,
206 location: CheckLocation::Settings,
207 message: "Settings.secret_key is still set to the insecure dev default in Environment::Prod. This is a hard production risk.".to_string(),
208 hint: Some("set UMBRAL_SECRET_KEY in your production env, or change `secret_key` in umbral.toml.".to_string()),
209 });
210 return findings;
211 }
212 // The default for Environment is Dev, so an operator who never
213 // sets UMBRAL_ENVIRONMENT slips past the strict check above. Add a
214 // bind-address heuristic: if we're binding to something other than
215 // loopback, treat it as likely-public and warn.
216 if insecure && !is_loopback_bind(&ctx.settings.bind_addr) {
217 findings.push(SystemCheckFinding {
218 check_id: "settings.required",
219 severity: Severity::Warning,
220 location: CheckLocation::Settings,
221 message: format!(
222 "Settings.secret_key is the insecure dev default, but bind_addr `{}` doesn't look like loopback. Set UMBRAL_ENVIRONMENT=Prod if this is a production deployment so the boot-check fails loudly instead of just warning.",
223 ctx.settings.bind_addr,
224 ),
225 hint: Some("set UMBRAL_SECRET_KEY, or restrict bind_addr to 127.0.0.1 for local dev.".to_string()),
226 });
227 }
228 findings
229}
230
231/// Warn when the server binds a non-loopback address but Host-header
232/// validation isn't enforced. `App::build` only mounts the
233/// `allowed_hosts` guard under [`Environment::Prod`] (see
234/// `app.rs`); a deployment that binds `0.0.0.0` while still flagged
235/// `Dev` therefore accepts *any* `Host` header — the classic vector
236/// for cache-poisoning and poisoned password-reset links.
237///
238/// The Prod path already enforces, so this only fires outside Prod,
239/// and only on a non-loopback bind (a local `127.0.0.1` dev server is
240/// not reachable with a forged Host from the network). It's a warning,
241/// not a boot-blocking error, for the same reason the insecure-key
242/// non-loopback case is: surprising a homelab test with a hard failure
243/// would be worse than the nudge.
244fn settings_host_validation(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
245 if !host_validation_unenforced(&ctx.settings.environment, &ctx.settings.bind_addr) {
246 return Vec::new();
247 }
248 vec![SystemCheckFinding {
249 check_id: "settings.host_validation",
250 severity: Severity::Warning,
251 location: CheckLocation::Settings,
252 message: format!(
253 "bind_addr `{}` is not loopback, but Host-header validation is only enforced in Environment::Prod. This deployment accepts any Host header (cache-poisoning / poisoned-reset-link risk).",
254 ctx.settings.bind_addr,
255 ),
256 hint: Some(
257 "set UMBRAL_ENVIRONMENT=Prod (enforces allowed_hosts), or bind 127.0.0.1 for local dev."
258 .to_string(),
259 ),
260 }]
261}
262
263/// Pure predicate behind [`settings_host_validation`]: Host validation
264/// is unenforced when we're *not* in Prod yet bound to a non-loopback
265/// address. Split out so it's testable without constructing a full
266/// [`CheckContext`] (which needs a live backend).
267fn host_validation_unenforced(environment: &Environment, bind_addr: &str) -> bool {
268 !matches!(environment, Environment::Prod) && !is_loopback_bind(bind_addr)
269}
270
271/// True when `bind_addr` parses as the loopback interface — i.e.
272/// `127.0.0.1`, `::1`, or `localhost`. Anything else is treated as
273/// likely public-facing for the secret_key defence-in-depth check.
274fn is_loopback_bind(bind_addr: &str) -> bool {
275 // The setting is `host:port`; split off the port and inspect the
276 // host. Fall back to a string-prefix check for IPv6 brackets.
277 let host = bind_addr
278 .rsplit_once(':')
279 .map(|(host, _)| host)
280 .unwrap_or(bind_addr)
281 .trim_start_matches('[')
282 .trim_end_matches(']');
283 host == "127.0.0.1" || host == "::1" || host == "localhost" || host.is_empty()
284}
285
286/// Warn when `allowed_hosts` is still the dev default in
287/// `Environment::Prod`. A real prod app almost never serves only
288/// loopback; logging this gives the operator a nudge while letting the
289/// build proceed.
290fn settings_allowed_hosts(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
291 let mut findings = Vec::new();
292 if matches!(ctx.settings.environment, Environment::Prod)
293 && ctx.settings.allowed_hosts.len() == DEFAULT_ALLOWED_HOSTS.len()
294 && ctx
295 .settings
296 .allowed_hosts
297 .iter()
298 .zip(DEFAULT_ALLOWED_HOSTS.iter())
299 .all(|(a, b)| a == b)
300 {
301 findings.push(SystemCheckFinding {
302 check_id: "settings.allowed_hosts",
303 severity: Severity::Warning,
304 location: CheckLocation::Settings,
305 message: "Settings.allowed_hosts is still the dev default [\"localhost\", \"127.0.0.1\"] in Environment::Prod. A real production deployment almost certainly serves a public hostname.".to_string(),
306 hint: Some("set UMBRAL_ALLOWED_HOSTS or `allowed_hosts` in umbral.toml to the hostnames this app actually serves.".to_string()),
307 });
308 }
309 findings
310}
311
312/// Warn when `log_level` is `debug` or `trace` in `Environment::Prod`.
313/// Verbose logging in production leaks internals into stdout and
314/// usually means a debug session was left on by accident.
315fn settings_log_level(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
316 let mut findings = Vec::new();
317 let level = ctx.settings.log_level.to_ascii_lowercase();
318 if matches!(ctx.settings.environment, Environment::Prod)
319 && (level == "debug" || level == "trace")
320 {
321 findings.push(SystemCheckFinding {
322 check_id: "settings.log_level",
323 severity: Severity::Warning,
324 location: CheckLocation::Settings,
325 message: format!(
326 "Settings.log_level is \"{}\" in Environment::Prod. Verbose logging in production leaks internals and adds noise.",
327 ctx.settings.log_level
328 ),
329 hint: Some("set UMBRAL_LOG_LEVEL to \"info\", \"warn\", or \"error\" for production deployments.".to_string()),
330 });
331 }
332 findings
333}
334
335/// Defensive invariant: the URL scheme in `database_url` should match
336/// the active backend's `name()`. Phase 2 picks the backend from the
337/// URL, so the two agree by construction today; this check exists so a
338/// future codepath that sets the backend manually can't silently drift.
339fn backend_url_scheme_matches_active_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
340 let mut findings = Vec::new();
341 let scheme = ctx
342 .settings
343 .database_url
344 .split_once(':')
345 .map(|(s, _)| s)
346 .unwrap_or("");
347 let expected_backend = match scheme {
348 "postgres" | "postgresql" => Some("postgres"),
349 "sqlite" => Some("sqlite"),
350 _ => None,
351 };
352 if let Some(expected) = expected_backend {
353 let active = ctx.backend.name();
354 if expected != active {
355 findings.push(SystemCheckFinding {
356 check_id: "backend.url_scheme.matches_active_backend",
357 severity: Severity::Error,
358 location: CheckLocation::Settings,
359 message: format!(
360 "Settings.database_url scheme \"{scheme}\" implies backend \"{expected}\", but the active backend is \"{active}\"."
361 ),
362 hint: Some("the URL and the active backend must agree; fix `database_url` in umbral.toml or whichever codepath overrode the backend.".to_string()),
363 });
364 }
365 }
366 findings
367}
368
369/// Walk every registered model and fail at boot when a field's type
370/// is incompatible with the active backend.
371///
372/// Phase 4.1 ships exactly one gated type: `SqlType::Array(_)`, which
373/// only works on Postgres. The check matches on the `Column::ty`
374/// stored in the migrate registry directly, rather than walking back
375/// to `Model::FIELDS` for the `supported_backends` slice (the latter
376/// isn't carried on `migrate::Column`). When the next Postgres-only
377/// `SqlType` variant lands (HStore, FullTextSearch, etc.), it gets
378/// added to the `is_postgres_only` match below.
379///
380/// **Error**, not Warning: a field rendered against the wrong backend
381/// produces incorrect DDL or a runtime panic deep inside `bind_value`.
382/// Boot-time failure with a clear message is the right behaviour.
383fn field_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
384 let mut findings = Vec::new();
385 let active = ctx.backend.name();
386 if active == "postgres" {
387 // No Postgres-only type is rejected on Postgres; the SQLite
388 // side does the rejecting. Early return keeps the registry
389 // walk out of the hot path on Postgres boots.
390 return findings;
391 }
392 // Low-level tests that drive `run_all` without booting an App
393 // never publish the model registry; the check would panic on
394 // `registered_plugins()`. Skip silently — there are no models to
395 // walk anyway.
396 if !crate::migrate::is_initialised() {
397 return findings;
398 }
399
400 for plugin in crate::migrate::registered_plugins() {
401 for model in crate::migrate::models_for_plugin(&plugin) {
402 for field in &model.fields {
403 // IMP-5: per-field backend gate via
404 // `#[umbral(backend = "postgres")]`. When the slice
405 // is non-empty and the active backend isn't listed,
406 // reject at boot with a clear message. The
407 // hardcoded `is_postgres_only` branch below remains
408 // for types the framework knows about; the
409 // declared-list path covers user-facing attribute
410 // shape.
411 if !field.supported_backends.is_empty()
412 && !field.supported_backends.iter().any(|b| b == active)
413 {
414 findings.push(SystemCheckFinding {
415 check_id: "field.backend",
416 severity: Severity::Error,
417 location: CheckLocation::Settings,
418 message: format!(
419 "Field `{plugin}::{}::{}` declares `#[umbral(backend = ...)]` \
420 as {:?}, but the active backend is `{active}`.",
421 model.name, field.name, field.supported_backends,
422 ),
423 hint: Some(format!(
424 "switch UMBRAL_DATABASE_URL to a backend matching one of \
425 {:?}, or drop the `backend` attribute and pick a portable \
426 field type.",
427 field.supported_backends,
428 )),
429 });
430 continue;
431 }
432 if is_postgres_only(field.ty) {
433 findings.push(SystemCheckFinding {
434 check_id: "field.backend",
435 severity: Severity::Error,
436 location: CheckLocation::Settings,
437 message: format!(
438 "Field `{plugin}::{}::{}` has type {:?} which is Postgres-only, but the active backend is `{active}`.",
439 model.name, field.name, field.ty,
440 ),
441 hint: Some(
442 "switch UMBRAL_DATABASE_URL to a `postgres://...` URL, \
443 or change the field to a portable type — \
444 `serde_json::Value` (SqlType::Json) is the closest \
445 portable analogue to an array."
446 .to_string(),
447 ),
448 });
449 }
450 }
451 }
452 }
453 findings
454}
455
456/// Fail at boot when a model declares a `FileField` / `ImageField`
457/// (detected by the column's `widget` being `"file"` or `"image"`) but
458/// no registered plugin provides a [`Storage`](crate::storage::Storage)
459/// backend.
460///
461/// **Why the capability flag, not the ambient `storage_opt()`:** a
462/// `Storage` backend is registered in `Plugin::on_ready`, which runs
463/// *after* the system-check phase (see `App::build`'s phase ordering).
464/// So at check time `crate::storage::storage_opt()` is still `None` even
465/// when `StoragePlugin` is wired and *will* register a backend a moment
466/// later. Checking the ambient here would false-positive on every app
467/// that uses media. Instead we read `ctx.provides_storage`, which
468/// `App::build` computes from the sorted plugin list's
469/// `Plugin::provides_storage()` flags — the *declared capability*, which
470/// is knowable at check time.
471///
472/// **Error**, not Warning: a file/image field with no backend means
473/// `FileField::url` silently falls back to the raw key, producing broken
474/// `<img src>` / download links in production. Failing the build with a
475/// clear fix is the right behaviour.
476fn field_storage_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
477 let mut findings = Vec::new();
478 // A backend is (or will be) registered — nothing to check.
479 if ctx.provides_storage {
480 return findings;
481 }
482 // Low-level tests that drive `run_all` without booting an App never
483 // publish the model registry; skip silently (there are no models to
484 // walk anyway, same guard as `field_backend`).
485 if !crate::migrate::is_initialised() {
486 return findings;
487 }
488 for plugin in crate::migrate::registered_plugins() {
489 for model in crate::migrate::models_for_plugin(&plugin) {
490 for field in &model.fields {
491 let is_file_field = matches!(field.widget.as_deref(), Some("file") | Some("image"));
492 if !is_file_field {
493 continue;
494 }
495 // Leak the owned strings into the finding's
496 // &'static-typed location. The walk runs once at boot, so
497 // the small leak is acceptable and matches the
498 // location-string contract (Field carries &'static str).
499 findings.push(SystemCheckFinding {
500 check_id: "field.storage_backend",
501 severity: Severity::Error,
502 location: CheckLocation::Field {
503 plugin: Box::leak(plugin.clone().into_boxed_str()),
504 model: Box::leak(model.name.clone().into_boxed_str()),
505 field: Box::leak(field.name.clone().into_boxed_str()),
506 },
507 message: format!(
508 "Model `{plugin}::{}` field `{}` declares a file/image field, \
509 but no Storage backend is registered.",
510 model.name, field.name,
511 ),
512 hint: Some(
513 "add `StoragePlugin` to your app (it registers a filesystem Storage \
514 backend), or call `umbral::storage::set_storage(...)` before \
515 `App::build()` to wire a custom backend."
516 .to_string(),
517 ),
518 });
519 }
520 }
521 }
522 findings
523}
524
525/// Walk every registered model and fail at boot when a `choices`
526/// column's declared default isn't one of the column's choices.
527///
528/// **Why this exists (gaps2 #32):** a choices field's default lands
529/// verbatim in DDL (`migrate.rs`'s `def.default(col.default.clone())`),
530/// so writing `#[umbral(default = "PostStatus::Draft")]` — the Rust enum
531/// *path* instead of the stored DB literal `"draft"` — ships a broken
532/// schema. Postgres rejects the row at insert via the `CHECK (col IN
533/// (...))` constraint; SQLite stores the undecodable text and errors on
534/// the next `SELECT` when the `ChoiceField` decoder can't map it back.
535/// Per the "backend mismatches caught at boot" principle, this surfaces
536/// the mistake at build time with a clear message instead of in prod.
537///
538/// The check works off `Column.choices`, which already holds the DB
539/// values (`FieldSpec::choices`), so `choices` *is* the allowed set —
540/// no need to reach for `ChoiceField::VALUES`. When the bad default
541/// contains `::` (the tell-tale of a pasted Rust enum path), we lower
542/// the part after the last `::` and, if that matches a real choice,
543/// emit a did-you-mean for the stored literal.
544///
545/// **Error**, not Warning: the DDL is wrong and the table is unusable.
546fn field_choices_default(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
547 let mut findings = Vec::new();
548 // Low-level tests that drive `run_all` without booting an App never
549 // publish the model registry; skip silently (same guard as the
550 // other model-walking checks).
551 if !crate::migrate::is_initialised() {
552 return findings;
553 }
554 for plugin in crate::migrate::registered_plugins() {
555 for model in crate::migrate::models_for_plugin(&plugin) {
556 for field in &model.fields {
557 // Only choices columns with an explicit default can be
558 // wrong this way: a non-choices column has no allowed
559 // set to violate, and an empty default emits no DDL
560 // `DEFAULT` at all.
561 if field.choices.is_empty()
562 || field.default.is_empty()
563 || field.choices.contains(&field.default)
564 {
565 continue;
566 }
567 let hint = if field.default.contains("::") {
568 // `Foo::Bar` → `bar`; choices are typically declared
569 // with `rename_all = "lowercase"`, so lower the tail
570 // before checking for a match.
571 let suggested = field
572 .default
573 .rsplit("::")
574 .next()
575 .unwrap_or(&field.default)
576 .to_lowercase();
577 if field.choices.contains(&suggested) {
578 format!(
579 "Did you mean the DB literal `{suggested}`? Choices defaults are \
580 the stored value (e.g. `\"draft\"`), not the Rust enum path \
581 (`\"PostStatus::Draft\"`)."
582 )
583 } else {
584 format!(
585 "Set the default to one of the stored values: [{}].",
586 field.choices.join(", "),
587 )
588 }
589 } else {
590 format!(
591 "Set the default to one of the stored values: [{}].",
592 field.choices.join(", "),
593 )
594 };
595 // Leak the owned strings into the finding's
596 // &'static-typed location — the walk runs once at boot,
597 // matching the storage check's pattern.
598 findings.push(SystemCheckFinding {
599 check_id: "field.choices_default",
600 severity: Severity::Error,
601 location: CheckLocation::Field {
602 plugin: Box::leak(plugin.clone().into_boxed_str()),
603 model: Box::leak(model.name.clone().into_boxed_str()),
604 field: Box::leak(field.name.clone().into_boxed_str()),
605 },
606 message: format!(
607 "Model `{plugin}::{}` field `{}` has default `{}` which is not one \
608 of its choices: [{}].",
609 model.name,
610 field.name,
611 field.default,
612 field.choices.join(", "),
613 ),
614 hint: Some(hint),
615 });
616 }
617 }
618 }
619 findings
620}
621
622/// Warn when `AuthPlugin` or `SessionsPlugin` is registered but
623/// `SecurityPlugin` is NOT.
624///
625/// An app that handles authenticated or session traffic with no
626/// `SecurityPlugin` has **no CSRF protection and no hardening headers**
627/// (CSP, Strict-Transport-Security, X-Frame-Options, etc.) — an
628/// easy-to-miss footgun. The check is a **Warning** (boot continues)
629/// because some apps legitimately handle CSRF through other means (a
630/// reverse-proxy header, a separate middleware, or a custom plugin).
631///
632/// Gaps2 #25 (scaffold-independent half): the scaffold half that auto-
633/// mounts `SecurityPlugin` in `umbral startproject` is deferred until the
634/// #8 scaffold lands.
635fn plugin_security_missing(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
636 let names = ctx.registered_plugin_names;
637 let has_auth = names.contains(&"auth");
638 let has_sessions = names.contains(&"sessions");
639 if !(has_auth || has_sessions) {
640 // Neither auth nor sessions — nothing to warn about.
641 return Vec::new();
642 }
643 if names.contains(&"security") {
644 // SecurityPlugin is present — all good.
645 return Vec::new();
646 }
647 let who = match (has_auth, has_sessions) {
648 (true, true) => "AuthPlugin and SessionsPlugin are",
649 (true, false) => "AuthPlugin is",
650 (false, true) => "SessionsPlugin is",
651 (false, false) => unreachable!(),
652 };
653 vec![SystemCheckFinding {
654 check_id: "plugin.security_missing",
655 severity: Severity::Warning,
656 location: CheckLocation::Settings,
657 message: format!(
658 "{who} mounted without SecurityPlugin — requests have no CSRF \
659 protection or security headers (CSP, HSTS, X-Frame-Options, …). \
660 Add `.plugin(SecurityPlugin::new())` to your App builder, or \
661 handle CSRF / headers through another mechanism.",
662 ),
663 hint: Some(
664 "add `.plugin(umbral_security::SecurityPlugin::new())` to your \
665 `App::builder()` call."
666 .to_string(),
667 ),
668 }]
669}
670
671/// True for `SqlType` variants that only work on Postgres. Phase 4.1
672/// added `Array(_)`; Phase 4.4 adds `Inet`, `Cidr`, `MacAddr`. Future
673/// Postgres-only types (HStore, FullTextSearch) get added to this
674/// match.
675fn is_postgres_only(ty: crate::orm::SqlType) -> bool {
676 use crate::orm::SqlType;
677 matches!(
678 ty,
679 SqlType::Array(_)
680 | SqlType::Inet
681 | SqlType::Cidr
682 | SqlType::MacAddr
683 // gaps2 #70: text-backed Postgres types (XML / LTREE /
684 // BIT VARYING) have no SQLite equivalent; the boot check
685 // rejects them on SQLite the same way as the network types.
686 | SqlType::Xml
687 | SqlType::Ltree
688 | SqlType::Bit
689 | SqlType::FullText
690 // BUG-10: sqlx's `rust_decimal` Encode/Decode is
691 // Postgres-only. SQLite has no native NUMERIC type;
692 // any model with a Decimal column fails the boot
693 // check the same way Array does.
694 | SqlType::Decimal
695 )
696}
697
698/// Run every check in `checks` against `ctx`, accumulate findings, and
699/// partition into errors vs warnings. Used by `AppBuilder::build()`
700/// phase 4 and by tests.
701///
702/// Returns the full findings list; callers decide what to do with the
703/// Error-severity entries (the builder turns them into
704/// `BuildError::SystemCheckFailed`).
705pub fn run_all(ctx: &CheckContext<'_>, checks: &[SystemCheck]) -> Vec<SystemCheckFinding> {
706 let mut findings = Vec::new();
707 for check in checks {
708 findings.extend((check.run)(ctx));
709 }
710 findings
711}
712
713#[cfg(test)]
714mod tests {
715 use super::{host_validation_unenforced, is_loopback_bind};
716 use crate::settings::Environment;
717
718 #[test]
719 fn loopback_binds_are_recognised() {
720 assert!(is_loopback_bind("127.0.0.1:8000"));
721 assert!(is_loopback_bind("localhost:3000"));
722 assert!(is_loopback_bind("[::1]:8080"));
723 assert!(is_loopback_bind(":8000")); // host omitted → local
724 assert!(!is_loopback_bind("0.0.0.0:8000"));
725 assert!(!is_loopback_bind("192.168.1.10:8000"));
726 }
727
728 #[test]
729 fn host_validation_warns_only_off_prod_and_non_loopback() {
730 // Non-loopback + not Prod → unenforced (warn).
731 assert!(host_validation_unenforced(
732 &Environment::Dev,
733 "0.0.0.0:8000"
734 ));
735 // Prod enforces regardless of bind.
736 assert!(!host_validation_unenforced(
737 &Environment::Prod,
738 "0.0.0.0:8000"
739 ));
740 // Loopback bind is not network-reachable with a forged Host.
741 assert!(!host_validation_unenforced(
742 &Environment::Dev,
743 "127.0.0.1:8000"
744 ));
745 }
746}