Skip to main content

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}