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/// Minimum acceptable `secret_key` length in `Environment::Prod`. A short key
30/// forges sessions / CSRF tokens / signed values just as the dev default does
31/// (audit_2 core-app-config #2 / H15). 32 chars ~= 192 bits at base64ish density.
32const MIN_SECRET_KEY_LEN: usize = 32;
33
34/// The hard-error message when `secret_key` is unacceptable for `Environment::Prod`
35/// — the insecure dev default OR too short to be a real signing key — else `None`.
36/// Pure + testable (the `settings_required` check just renders this into a finding).
37fn prod_secret_key_error(env: &Environment, secret_key: &str) -> Option<String> {
38    if !matches!(env, Environment::Prod) {
39        return None;
40    }
41    if secret_key == INSECURE_DEV_SECRET_KEY {
42        return Some(
43            "Settings.secret_key is still set to the insecure dev default in \
44             Environment::Prod. This is a hard production risk."
45                .to_string(),
46        );
47    }
48    let len = secret_key.trim().len();
49    if len < MIN_SECRET_KEY_LEN {
50        return Some(format!(
51            "Settings.secret_key is too short ({len} chars) in Environment::Prod; use at least \
52             {MIN_SECRET_KEY_LEN} random characters. A weak key lets an attacker forge sessions, \
53             CSRF tokens, and signed values just like the dev default does."
54        ));
55    }
56    None
57}
58
59/// The default `allowed_hosts` list emitted by
60/// `crate::settings::default_allowed_hosts()`. Mirrored here so the
61/// `settings.allowed_hosts` check can detect "still the dev default"
62/// without allocating.
63const DEFAULT_ALLOWED_HOSTS: &[&str] = &["localhost", "127.0.0.1"];
64
65/// One named system check.
66///
67/// Built-in checks live in `framework_checks()`; plugin checks return
68/// from `Plugin::system_checks()` (M7). Each check is a function pointer
69/// that takes the [`CheckContext`] and produces zero or more
70/// [`SystemCheckFinding`]s.
71pub struct SystemCheck {
72    /// Stable identifier, dot-delimited. Used in error reports and so
73    /// users can grep for failures: `field.backend`, `settings.required`,
74    /// etc.
75    pub id: &'static str,
76    /// The check function.
77    pub run: fn(&CheckContext<'_>) -> Vec<SystemCheckFinding>,
78}
79
80/// Context available to a system check at boot.
81///
82/// Holds references to everything a check might consult: the active
83/// backend, the validated settings. The model list (M5) and plugin
84/// registry (M7) get added when they exist.
85pub struct CheckContext<'a> {
86    /// The active database backend.
87    pub backend: &'a dyn DatabaseBackend,
88    /// The runtime settings, post-load, pre-publish.
89    pub settings: &'a Settings,
90    /// `true` when at least one registered plugin reports
91    /// [`crate::plugin::Plugin::provides_storage`]. The
92    /// `field.storage_backend` check reads this to decide whether a
93    /// model with a `FileField` / `ImageField` has a backend to resolve
94    /// uploads through.
95    ///
96    /// This is the *capability flag* of the plugin list, not the ambient
97    /// `crate::storage::storage_opt()` — storage is registered in
98    /// `on_ready`, which runs *after* this check, so the ambient backend
99    /// isn't published yet at check time. `App::build` populates this
100    /// from the sorted plugin list before running the checks. Tests that
101    /// build a `CheckContext` by hand (without a plugin walk) set `true`
102    /// to keep the storage check inert.
103    pub provides_storage: bool,
104    /// The names of every registered plugin, in topological order, as
105    /// returned by [`crate::plugin::Plugin::name`]. Populated by
106    /// `App::build` before running phase 4 checks. Tests that build a
107    /// `CheckContext` by hand should supply an empty slice (`&[]`) to
108    /// make plugin-aware checks that need a specific set of names inert,
109    /// or supply the names they want to exercise directly.
110    pub registered_plugin_names: &'a [&'a str],
111}
112
113/// One issue surfaced by a system check.
114#[derive(Debug)]
115pub struct SystemCheckFinding {
116    /// The id of the check that produced this finding. Matches the
117    /// owning [`SystemCheck::id`].
118    pub check_id: &'static str,
119    /// Whether this is an error (blocks boot) or just a warning (logged
120    /// and proceeds).
121    pub severity: Severity,
122    /// The thing that's broken: which model, which field, which plugin,
123    /// which route, or just "the settings."
124    pub location: CheckLocation,
125    /// A user-facing one-line message.
126    pub message: String,
127    /// Optional follow-up: what the user should change to fix it.
128    pub hint: Option<String>,
129}
130
131/// Severity of a system-check finding.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Severity {
134    /// Block boot. `AppBuilder::build()` returns
135    /// `BuildError::SystemCheckFailed`.
136    Error,
137    /// Log via `tracing::warn!`, continue booting.
138    Warning,
139}
140
141/// Where in the framework a finding originates. The variants grow as
142/// the registries do.
143#[derive(Debug, Clone)]
144pub enum CheckLocation {
145    /// A field on a model. M5/M7 work.
146    Field {
147        plugin: &'static str,
148        model: &'static str,
149        field: &'static str,
150    },
151    /// A model. M5/M7 work.
152    Model {
153        plugin: &'static str,
154        model: &'static str,
155    },
156    /// A plugin's own metadata. M7 work.
157    Plugin { plugin: &'static str },
158    /// A registered route. M7 work.
159    Route { path: String },
160    /// The settings as a whole.
161    Settings,
162}
163
164/// Return the framework's built-in checks.
165///
166/// At M4 the catalogue is intentionally short: there's no model
167/// registry (M5) or plugin walk (M7) yet, so only checks that read
168/// purely from `Settings` and the active backend are meaningful. The
169/// rest of the built-in catalogue (`field.backend`, `model.pk.present`,
170/// `model.table.unique`, `route.collision`, `plugin.dependency.*`)
171/// lands alongside the registries it needs.
172pub fn framework_checks() -> Vec<SystemCheck> {
173    vec![
174        SystemCheck {
175            id: "model.soft_delete_cascade",
176            run: soft_delete_cascade_targets,
177        },
178        SystemCheck {
179            id: "model.auto_user",
180            run: auto_user_columns_nullable,
181        },
182        SystemCheck {
183            id: "model.materialized_view",
184            run: materialized_view_backend,
185        },
186        SystemCheck {
187            id: "settings.required",
188            run: settings_required,
189        },
190        SystemCheck {
191            id: "settings.allowed_hosts",
192            run: settings_allowed_hosts,
193        },
194        SystemCheck {
195            id: "settings.allowed_hosts_wildcard",
196            run: settings_allowed_hosts_wildcard,
197        },
198        SystemCheck {
199            id: "settings.sqlite_in_prod",
200            run: settings_sqlite_in_prod,
201        },
202        SystemCheck {
203            id: "settings.host_validation",
204            run: settings_host_validation,
205        },
206        SystemCheck {
207            id: "settings.log_level",
208            run: settings_log_level,
209        },
210        SystemCheck {
211            id: "backend.url_scheme.matches_active_backend",
212            run: backend_url_scheme_matches_active_backend,
213        },
214        SystemCheck {
215            id: "field.backend",
216            run: field_backend,
217        },
218        SystemCheck {
219            id: "field.storage_backend",
220            run: field_storage_backend,
221        },
222        SystemCheck {
223            id: "field.choices_default",
224            run: field_choices_default,
225        },
226        SystemCheck {
227            id: "field.case_insensitive.sqlite_ascii",
228            run: field_case_insensitive_sqlite_ascii,
229        },
230        SystemCheck {
231            id: "plugin.security_missing",
232            run: plugin_security_missing,
233        },
234    ]
235}
236
237/// Verify that `secret_key` is not the insecure dev default. Two
238/// layers:
239///
240/// 1. **Hard error in `Environment::Prod`** — the original check.
241///    Blocks boot when the operator self-identifies as production.
242/// 2. **Warning when the bind address looks public** — defense in
243///    depth for the operator who forgot to set
244///    `UMBRAL_ENVIRONMENT=Prod`. If `bind_addr` isn't `127.0.0.1` or
245///    `localhost`, the process is likely serving real network
246///    traffic, and the insecure dev key is dangerous regardless of
247///    the declared environment.
248///
249/// The boot-blocking error is intentionally reserved for explicit
250/// production declarations — surprising people with a build failure
251/// because they bound to `0.0.0.0` in a homelab test would be worse
252/// than the warning. The warning is the visible nudge.
253fn settings_required(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
254    let mut findings = Vec::new();
255    let insecure = ctx.settings.secret_key == INSECURE_DEV_SECRET_KEY;
256    if let Some(message) =
257        prod_secret_key_error(&ctx.settings.environment, &ctx.settings.secret_key)
258    {
259        findings.push(SystemCheckFinding {
260            check_id: "settings.required",
261            severity: Severity::Error,
262            location: CheckLocation::Settings,
263            message,
264            hint: Some("set a long, random UMBRAL_SECRET_KEY (>= 32 chars) in your production env, or change `secret_key` in umbral.toml.".to_string()),
265        });
266        return findings;
267    }
268    // The default for Environment is Dev, so an operator who never
269    // sets UMBRAL_ENVIRONMENT slips past the strict check above. Add a
270    // bind-address heuristic: if we're binding to something other than
271    // loopback, treat it as likely-public and warn.
272    if insecure && !is_loopback_bind(&ctx.settings.bind_addr) {
273        findings.push(SystemCheckFinding {
274            check_id: "settings.required",
275            severity: Severity::Warning,
276            location: CheckLocation::Settings,
277            message: format!(
278                "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.",
279                ctx.settings.bind_addr,
280            ),
281            hint: Some("set UMBRAL_SECRET_KEY, or restrict bind_addr to 127.0.0.1 for local dev.".to_string()),
282        });
283    }
284    findings
285}
286
287/// Warn when the server binds a non-loopback address but Host-header
288/// validation isn't enforced. `App::build` only mounts the
289/// `allowed_hosts` guard under [`Environment::Prod`] (see
290/// `app.rs`); a deployment that binds `0.0.0.0` while still flagged
291/// `Dev` therefore accepts *any* `Host` header — the classic vector
292/// for cache-poisoning and poisoned password-reset links.
293///
294/// The Prod path already enforces, so this only fires outside Prod,
295/// and only on a non-loopback bind (a local `127.0.0.1` dev server is
296/// not reachable with a forged Host from the network). It's a warning,
297/// not a boot-blocking error, for the same reason the insecure-key
298/// non-loopback case is: surprising a homelab test with a hard failure
299/// would be worse than the nudge.
300fn settings_host_validation(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
301    if !host_validation_unenforced(&ctx.settings.environment, &ctx.settings.bind_addr) {
302        return Vec::new();
303    }
304    vec![SystemCheckFinding {
305        check_id: "settings.host_validation",
306        severity: Severity::Warning,
307        location: CheckLocation::Settings,
308        message: format!(
309            "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).",
310            ctx.settings.bind_addr,
311        ),
312        hint: Some(
313            "set UMBRAL_ENVIRONMENT=Prod (enforces allowed_hosts), or bind 127.0.0.1 for local dev."
314                .to_string(),
315        ),
316    }]
317}
318
319/// Pure predicate behind [`settings_host_validation`]: Host validation
320/// is unenforced when we're *not* in Prod yet bound to a non-loopback
321/// address. Split out so it's testable without constructing a full
322/// [`CheckContext`] (which needs a live backend).
323fn host_validation_unenforced(environment: &Environment, bind_addr: &str) -> bool {
324    !matches!(environment, Environment::Prod) && !is_loopback_bind(bind_addr)
325}
326
327/// True when `bind_addr` parses as the loopback interface — i.e.
328/// `127.0.0.1`, `::1`, or `localhost`. Anything else is treated as
329/// likely public-facing for the secret_key defence-in-depth check.
330fn is_loopback_bind(bind_addr: &str) -> bool {
331    use std::net::{IpAddr, SocketAddr};
332    // Prefer real address parsing so IPv6 classifies correctly. `rsplit(':')`
333    // alone mangles a bare `::1` into host `::` (each colon is a candidate
334    // separator), misclassifying loopback as public (audit_2 findings #16). A
335    // full `SocketAddr` parse handles `[::1]:8000` / `127.0.0.1:8000`; a bare
336    // `IpAddr` parse handles `::1` / `127.0.0.1` with no port.
337    if let Ok(sa) = bind_addr.parse::<SocketAddr>() {
338        return sa.ip().is_loopback();
339    }
340    if let Ok(ip) = bind_addr.parse::<IpAddr>() {
341        return ip.is_loopback();
342    }
343    // Fall back to host-string inspection for `host:port` / `localhost` forms
344    // that aren't parseable IPs.
345    let host = bind_addr
346        .rsplit_once(':')
347        .map(|(host, _)| host)
348        .unwrap_or(bind_addr)
349        .trim_start_matches('[')
350        .trim_end_matches(']');
351    host == "127.0.0.1" || host == "::1" || host == "localhost" || host.is_empty()
352}
353
354/// Warn when `allowed_hosts` is still the dev default in
355/// `Environment::Prod`. A real prod app almost never serves only
356/// loopback; logging this gives the operator a nudge while letting the
357/// build proceed.
358fn settings_allowed_hosts(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
359    let mut findings = Vec::new();
360    if matches!(ctx.settings.environment, Environment::Prod)
361        && ctx.settings.allowed_hosts.len() == DEFAULT_ALLOWED_HOSTS.len()
362        && ctx
363            .settings
364            .allowed_hosts
365            .iter()
366            .zip(DEFAULT_ALLOWED_HOSTS.iter())
367            .all(|(a, b)| a == b)
368    {
369        findings.push(SystemCheckFinding {
370            check_id: "settings.allowed_hosts",
371            severity: Severity::Warning,
372            location: CheckLocation::Settings,
373            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(),
374            hint: Some("set UMBRAL_ALLOWED_HOSTS or `allowed_hosts` in umbral.toml to the hostnames this app actually serves.".to_string()),
375        });
376    }
377    findings
378}
379
380/// Warn when `allowed_hosts` contains the `"*"` wildcard in
381/// `Environment::Prod` (audit_2 core-app-config #13). A wildcard makes the
382/// Prod-only Host-header guard accept *any* Host, silently defeating the very
383/// control it enforces — the cache-poisoning / poisoned-reset-link vector the
384/// guard exists to close. It's a Warning (some apps front the app with a proxy
385/// that already pins Host), but an explicit, deliberate downgrade should be
386/// visible in the boot log.
387/// An `auto_user` / `auto_user_add` column must be nullable (gaps3 #55).
388///
389/// Not every write has a user. A background job, a CLI command, a migration and
390/// an anonymous request all genuinely have no caller, and the framework stamps
391/// NULL rather than inventing an author. If the column is NOT NULL, that write
392/// dies on a constraint violation at runtime — so say it at boot instead.
393fn auto_user_columns_nullable(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
394    let Some(models) = crate::migrate::registered_models_opt() else {
395        return Vec::new();
396    };
397    let mut out = Vec::new();
398    for m in &models {
399        for col in &m.fields {
400            if (col.auto_user || col.auto_user_add) && !col.nullable {
401                out.push(SystemCheckFinding {
402                    check_id: "model.auto_user",
403                    severity: Severity::Error,
404                    location: CheckLocation::Settings,
405                    message: format!(
406                        "`{}.{}` is `#[umbral(auto_user)]` but NOT NULL. Writes without an \
407                         authenticated caller — a background task, a CLI command, a data \
408                         migration, an anonymous request — have no user to stamp, so the \
409                         framework writes NULL there rather than inventing an author. Against a \
410                         NOT NULL column that write fails at runtime.",
411                        m.table, col.name,
412                    ),
413                    hint: Some(format!(
414                        "make it nullable: `{}: Option<ForeignKey<AuthUser>>`.",
415                        col.name
416                    )),
417                });
418            }
419        }
420    }
421    out
422}
423
424/// A `soft_delete` parent must not have an `on_delete = "cascade"` child that
425/// cannot itself be soft-deleted (gaps3 #53).
426///
427/// `on_delete = "cascade"` promises *"when the parent goes, the child goes"*. If
428/// the parent's going is a soft delete (an `UPDATE`), the database never
429/// cascades — and if the child has no `deleted_at`, the cascade cannot follow
430/// either. We refuse to hard-delete the child (that would make a reversible
431/// operation irreversible, which is the one thing soft delete promises it is
432/// not), so the child would be silently left behind pointing at a deleted
433/// parent. An error at boot beats orphans in production.
434fn soft_delete_cascade_targets(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
435    crate::orm::soft_delete_cascade::check_cascade_targets()
436        .into_iter()
437        .map(|message| SystemCheckFinding {
438            check_id: "model.soft_delete_cascade",
439            severity: Severity::Error,
440            location: CheckLocation::Settings,
441            message,
442            hint: Some(
443                "mark the child `#[umbral(soft_delete)]` so the cascade can follow it, or change \
444                 the FK to `on_delete = \"set_null\"` / `\"restrict\"` if the child is meant to \
445                 outlive its parent."
446                    .to_string(),
447            ),
448        })
449        .collect()
450}
451
452fn settings_allowed_hosts_wildcard(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
453    if !allowed_hosts_has_wildcard(&ctx.settings.environment, &ctx.settings.allowed_hosts) {
454        return Vec::new();
455    }
456    vec![SystemCheckFinding {
457        check_id: "settings.allowed_hosts_wildcard",
458        severity: Severity::Warning,
459        location: CheckLocation::Settings,
460        message: "Settings.allowed_hosts contains the \"*\" wildcard in Environment::Prod — the \
461             Host-header guard accepts ANY Host, defeating host validation (cache-poisoning / \
462             poisoned-reset-link risk)."
463            .to_string(),
464        hint: Some(
465            "list the exact hostnames this app serves in UMBRAL_ALLOWED_HOSTS instead of \"*\"."
466                .to_string(),
467        ),
468    }]
469}
470
471/// Pure predicate behind [`settings_allowed_hosts_wildcard`]: a `"*"` entry
472/// while in Prod. Split out so it's testable without a live backend.
473fn allowed_hosts_has_wildcard(environment: &Environment, allowed_hosts: &[String]) -> bool {
474    matches!(environment, Environment::Prod) && allowed_hosts.iter().any(|h| h.trim() == "*")
475}
476
477/// Warn when the app runs on SQLite in `Environment::Prod` (audit_2
478/// core-app-config #13). SQLite is the framework's test/local-dev backend
479/// (Postgres-first per the design principles); a production deployment on
480/// SQLite gets a single-writer lock, no network concurrency, and no
481/// replica/pooling story. It's a Warning, not an error — small single-node
482/// apps legitimately ship on SQLite — but it should be a conscious choice, not
483/// a forgotten `sqlite::memory:` default.
484fn settings_sqlite_in_prod(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
485    if !is_sqlite_in_prod(&ctx.settings.environment, &ctx.settings.database_url) {
486        return Vec::new();
487    }
488    vec![SystemCheckFinding {
489        check_id: "settings.sqlite_in_prod",
490        severity: Severity::Warning,
491        location: CheckLocation::Settings,
492        message: format!(
493            "database_url `{}` is SQLite in Environment::Prod. SQLite is the dev/test backend \
494             (single writer, no network concurrency); production traffic wants Postgres.",
495            redact_url_userinfo(&ctx.settings.database_url),
496        ),
497        hint: Some(
498            "set UMBRAL_DATABASE_URL to a `postgres://...` URL for production, or keep SQLite \
499             deliberately for a small single-node deployment."
500                .to_string(),
501        ),
502    }]
503}
504
505/// Pure predicate behind [`settings_sqlite_in_prod`]: a `sqlite`-scheme URL
506/// (including the `sqlite::memory:` default) while in Prod. Split out so it's
507/// testable without a live backend.
508fn is_sqlite_in_prod(environment: &Environment, database_url: &str) -> bool {
509    matches!(environment, Environment::Prod)
510        && database_url
511            .split_once(':')
512            .map(|(scheme, _)| scheme.eq_ignore_ascii_case("sqlite"))
513            .unwrap_or(false)
514}
515
516/// Mask the userinfo of a connection URL for a boot-check message so a
517/// password embedded in `database_url` never lands in the log. Mirrors
518/// `crate::settings`'s own redaction; kept local so `check.rs` needn't reach
519/// into a sibling's private helper.
520fn redact_url_userinfo(url: &str) -> String {
521    let Some(scheme_end) = url.find("://") else {
522        return url.to_string();
523    };
524    let after = scheme_end + 3;
525    let authority_end = url[after..]
526        .find(['/', '?', '#'])
527        .map(|i| after + i)
528        .unwrap_or(url.len());
529    match url[after..authority_end].find('@') {
530        Some(at) => format!("{}***{}", &url[..after], &url[after + at..]),
531        None => url.to_string(),
532    }
533}
534
535/// Warn when `log_level` is `debug` or `trace` in `Environment::Prod`.
536/// Verbose logging in production leaks internals into stdout and
537/// usually means a debug session was left on by accident.
538fn settings_log_level(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
539    let mut findings = Vec::new();
540    let level = ctx.settings.log_level.to_ascii_lowercase();
541    if matches!(ctx.settings.environment, Environment::Prod)
542        && (level == "debug" || level == "trace")
543    {
544        findings.push(SystemCheckFinding {
545            check_id: "settings.log_level",
546            severity: Severity::Warning,
547            location: CheckLocation::Settings,
548            message: format!(
549                "Settings.log_level is \"{}\" in Environment::Prod. Verbose logging in production leaks internals and adds noise.",
550                ctx.settings.log_level
551            ),
552            hint: Some("set UMBRAL_LOG_LEVEL to \"info\", \"warn\", or \"error\" for production deployments.".to_string()),
553        });
554    }
555    findings
556}
557
558/// Defensive invariant: the URL scheme in `database_url` should match
559/// the active backend's `name()`. Phase 2 picks the backend from the
560/// URL, so the two agree by construction today; this check exists so a
561/// future codepath that sets the backend manually can't silently drift.
562fn backend_url_scheme_matches_active_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
563    let mut findings = Vec::new();
564    let scheme = ctx
565        .settings
566        .database_url
567        .split_once(':')
568        .map(|(s, _)| s)
569        .unwrap_or("");
570    let expected_backend = match scheme {
571        "postgres" | "postgresql" => Some("postgres"),
572        "sqlite" => Some("sqlite"),
573        _ => None,
574    };
575    if let Some(expected) = expected_backend {
576        let active = ctx.backend.name();
577        if expected != active {
578            findings.push(SystemCheckFinding {
579                check_id: "backend.url_scheme.matches_active_backend",
580                severity: Severity::Error,
581                location: CheckLocation::Settings,
582                message: format!(
583                    "Settings.database_url scheme \"{scheme}\" implies backend \"{expected}\", but the active backend is \"{active}\"."
584                ),
585                hint: Some("the URL and the active backend must agree; fix `database_url` in umbral.toml or whichever codepath overrode the backend.".to_string()),
586            });
587        }
588    }
589    findings
590}
591
592/// Walk every registered model and fail at boot when a field's type
593/// is incompatible with the active backend.
594///
595/// Phase 4.1 ships exactly one gated type: `SqlType::Array(_)`, which
596/// only works on Postgres. The check matches on the `Column::ty`
597/// stored in the migrate registry directly, rather than walking back
598/// to `Model::FIELDS` for the `supported_backends` slice (the latter
599/// isn't carried on `migrate::Column`). When the next Postgres-only
600/// `SqlType` variant lands (HStore, FullTextSearch, etc.), it gets
601/// added to the `is_postgres_only` match below.
602///
603/// **Error**, not Warning: a field rendered against the wrong backend
604/// produces incorrect DDL or a runtime panic deep inside `bind_value`.
605/// Boot-time failure with a clear message is the right behaviour.
606fn field_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
607    let mut findings = Vec::new();
608    let active = ctx.backend.name();
609    if active == "postgres" {
610        // No Postgres-only type is rejected on Postgres; the SQLite
611        // side does the rejecting. Early return keeps the registry
612        // walk out of the hot path on Postgres boots.
613        return findings;
614    }
615    // Low-level tests that drive `run_all` without booting an App
616    // never publish the model registry; the check would panic on
617    // `registered_plugins()`. Skip silently — there are no models to
618    // walk anyway.
619    if !crate::migrate::is_initialised() {
620        return findings;
621    }
622
623    for plugin in crate::migrate::registered_plugins() {
624        for model in crate::migrate::models_for_plugin(&plugin) {
625            for field in &model.fields {
626                // IMP-5: per-field backend gate via
627                // `#[umbral(backend = "postgres")]`. When the slice
628                // is non-empty and the active backend isn't listed,
629                // reject at boot with a clear message. The
630                // hardcoded `is_postgres_only` branch below remains
631                // for types the framework knows about; the
632                // declared-list path covers user-facing attribute
633                // shape.
634                if !field.supported_backends.is_empty()
635                    && !field.supported_backends.iter().any(|b| b == active)
636                {
637                    findings.push(SystemCheckFinding {
638                        check_id: "field.backend",
639                        severity: Severity::Error,
640                        location: CheckLocation::Settings,
641                        message: format!(
642                            "Field `{plugin}::{}::{}` declares `#[umbral(backend = ...)]` \
643                             as {:?}, but the active backend is `{active}`.",
644                            model.name, field.name, field.supported_backends,
645                        ),
646                        hint: Some(format!(
647                            "switch UMBRAL_DATABASE_URL to a backend matching one of \
648                             {:?}, or drop the `backend` attribute and pick a portable \
649                             field type.",
650                            field.supported_backends,
651                        )),
652                    });
653                    continue;
654                }
655                if is_postgres_only(field.ty) {
656                    findings.push(SystemCheckFinding {
657                        check_id: "field.backend",
658                        severity: Severity::Error,
659                        location: CheckLocation::Settings,
660                        message: format!(
661                            "Field `{plugin}::{}::{}` has type {:?} which is Postgres-only, but the active backend is `{active}`.",
662                            model.name, field.name, field.ty,
663                        ),
664                        hint: Some(
665                            "switch UMBRAL_DATABASE_URL to a `postgres://...` URL, \
666                             or change the field to a portable type — \
667                             `serde_json::Value` (SqlType::Json) is the closest \
668                             portable analogue to an array."
669                                .to_string(),
670                        ),
671                    });
672                }
673            }
674        }
675    }
676    findings
677}
678
679/// features #73 — fail at boot when a `#[umbral(materialized_view = "...")]` model
680/// is running against a backend that has no materialized views (i.e. SQLite).
681///
682/// **Error**, not Warning, and specifically NOT "render it as a plain view on
683/// SQLite". A plain view recomputes its SELECT on every read; a materialized one
684/// computes once and serves stored rows. Silently swapping them gives you a
685/// dev/test backend whose results are always correct and whose *performance
686/// contract is the opposite* of production's — which is precisely the bug you
687/// reached for a materialized view to avoid, now invisible until it is under load.
688/// The design principles call this out by name: never let the SQLite branch quietly
689/// diverge.
690fn materialized_view_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
691    let mut findings = Vec::new();
692    if ctx.backend.name() == "postgres" {
693        return findings;
694    }
695    if !crate::migrate::is_initialised() {
696        return findings;
697    }
698    let active = ctx.backend.name();
699    for plugin in crate::migrate::registered_plugins() {
700        for model in crate::migrate::models_for_plugin(&plugin) {
701            if !model.materialized {
702                continue;
703            }
704            findings.push(SystemCheckFinding {
705                check_id: "model.materialized_view",
706                severity: Severity::Error,
707                location: CheckLocation::Settings,
708                message: format!(
709                    "Model `{plugin}::{}` declares `#[umbral(materialized_view = ...)]`, \
710                     but the active backend is `{active}`, which has no materialized views.",
711                    model.name,
712                ),
713                hint: Some(
714                    "switch UMBRAL_DATABASE_URL to a `postgres://...` URL, or change the \
715                     attribute to `#[umbral(view = ...)]` — a plain view is portable. Note \
716                     that a plain view recomputes on every read, which is the cost a \
717                     materialized view exists to avoid: make that trade deliberately, do \
718                     not let the backend make it for you."
719                        .to_string(),
720                ),
721            });
722        }
723    }
724    findings
725}
726
727/// Fail at boot when a model declares a `FileField` / `ImageField`
728/// (detected by the column's `widget` being `"file"` or `"image"`) but
729/// no registered plugin provides a [`Storage`](crate::storage::Storage)
730/// backend.
731///
732/// **Why the capability flag, not the ambient `storage_opt()`:** a
733/// `Storage` backend is registered in `Plugin::on_ready`, which runs
734/// *after* the system-check phase (see `App::build`'s phase ordering).
735/// So at check time `crate::storage::storage_opt()` is still `None` even
736/// when `StoragePlugin` is wired and *will* register a backend a moment
737/// later. Checking the ambient here would false-positive on every app
738/// that uses media. Instead we read `ctx.provides_storage`, which
739/// `App::build` computes from the sorted plugin list's
740/// `Plugin::provides_storage()` flags — the *declared capability*, which
741/// is knowable at check time.
742///
743/// **Error**, not Warning: a file/image field with no backend means
744/// `FileField::url` silently falls back to the raw key, producing broken
745/// `<img src>` / download links in production. Failing the build with a
746/// clear fix is the right behaviour.
747fn field_storage_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
748    let mut findings = Vec::new();
749    // A backend is (or will be) registered — nothing to check.
750    if ctx.provides_storage {
751        return findings;
752    }
753    // Low-level tests that drive `run_all` without booting an App never
754    // publish the model registry; skip silently (there are no models to
755    // walk anyway, same guard as `field_backend`).
756    if !crate::migrate::is_initialised() {
757        return findings;
758    }
759    for plugin in crate::migrate::registered_plugins() {
760        for model in crate::migrate::models_for_plugin(&plugin) {
761            for field in &model.fields {
762                let is_file_field = matches!(field.widget.as_deref(), Some("file") | Some("image"));
763                if !is_file_field {
764                    continue;
765                }
766                // Leak the owned strings into the finding's
767                // &'static-typed location. The walk runs once at boot, so
768                // the small leak is acceptable and matches the
769                // location-string contract (Field carries &'static str).
770                findings.push(SystemCheckFinding {
771                    check_id: "field.storage_backend",
772                    severity: Severity::Error,
773                    location: CheckLocation::Field {
774                        plugin: Box::leak(plugin.clone().into_boxed_str()),
775                        model: Box::leak(model.name.clone().into_boxed_str()),
776                        field: Box::leak(field.name.clone().into_boxed_str()),
777                    },
778                    message: format!(
779                        "Model `{plugin}::{}` field `{}` declares a file/image field, \
780                         but no Storage backend is registered.",
781                        model.name, field.name,
782                    ),
783                    hint: Some(
784                        "add `StoragePlugin` to your app (it registers a filesystem Storage \
785                         backend), or call `umbral::storage::set_storage(...)` before \
786                         `App::build()` to wire a custom backend."
787                            .to_string(),
788                    ),
789                });
790            }
791        }
792    }
793    findings
794}
795
796/// Walk every registered model and fail at boot when a `choices`
797/// column's declared default isn't one of the column's choices.
798///
799/// **Why this exists (gaps2 #32):** a choices field's default lands
800/// verbatim in DDL (`migrate.rs`'s `def.default(col.default.clone())`),
801/// so writing `#[umbral(default = "PostStatus::Draft")]` — the Rust enum
802/// *path* instead of the stored DB literal `"draft"` — ships a broken
803/// schema. Postgres rejects the row at insert via the `CHECK (col IN
804/// (...))` constraint; SQLite stores the undecodable text and errors on
805/// the next `SELECT` when the `ChoiceField` decoder can't map it back.
806/// Per the "backend mismatches caught at boot" principle, this surfaces
807/// the mistake at build time with a clear message instead of in prod.
808///
809/// The check works off `Column.choices`, which already holds the DB
810/// values (`FieldSpec::choices`), so `choices` *is* the allowed set —
811/// no need to reach for `ChoiceField::VALUES`. When the bad default
812/// contains `::` (the tell-tale of a pasted Rust enum path), we lower
813/// the part after the last `::` and, if that matches a real choice,
814/// emit a did-you-mean for the stored literal.
815///
816/// **Error**, not Warning: the DDL is wrong and the table is unusable.
817/// gaps3 #35 — warn when `#[umbral(case_insensitive)]` is used on SQLite.
818///
819/// SQLite's `COLLATE NOCASE` folds only ASCII `A–Z`, so a case-insensitive
820/// UNIQUE / lookup won't treat, say, `Å` and `å` (or Turkish `İ`/`i`) as equal.
821/// Postgres `citext` folds per the database collation, so the check is a no-op
822/// there. A warning (not an error): the column still works for the ASCII names
823/// that are the overwhelming case; the developer just needs to know the limit.
824fn field_case_insensitive_sqlite_ascii(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
825    let mut findings = Vec::new();
826    if ctx.backend.name() != "sqlite" {
827        return findings;
828    }
829    if !crate::migrate::is_initialised() {
830        return findings;
831    }
832    for plugin in crate::migrate::registered_plugins() {
833        for model in crate::migrate::models_for_plugin(&plugin) {
834            for field in &model.fields {
835                if !field.case_insensitive {
836                    continue;
837                }
838                findings.push(SystemCheckFinding {
839                    check_id: "field.case_insensitive.sqlite_ascii",
840                    severity: Severity::Warning,
841                    location: CheckLocation::Settings,
842                    message: format!(
843                        "Field `{plugin}::{}::{}` is `#[umbral(case_insensitive)]`, but the active \
844                         backend is SQLite, whose `COLLATE NOCASE` folds ASCII A–Z only — \
845                         non-ASCII letters are compared case-sensitively.",
846                        model.name, field.name,
847                    ),
848                    hint: Some(
849                        "Fine for ASCII usernames/emails/slugs. If you need Unicode-correct \
850                         case-folding, use Postgres (citext folds per the DB collation), or \
851                         normalize with `#[umbral(lowercase)]` (Rust's to_lowercase is \
852                         Unicode-aware) when preserving the original casing isn't required."
853                            .to_string(),
854                    ),
855                });
856            }
857        }
858    }
859    findings
860}
861
862fn field_choices_default(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
863    let mut findings = Vec::new();
864    // Low-level tests that drive `run_all` without booting an App never
865    // publish the model registry; skip silently (same guard as the
866    // other model-walking checks).
867    if !crate::migrate::is_initialised() {
868        return findings;
869    }
870    for plugin in crate::migrate::registered_plugins() {
871        for model in crate::migrate::models_for_plugin(&plugin) {
872            for field in &model.fields {
873                // Only choices columns with an explicit default can be
874                // wrong this way: a non-choices column has no allowed
875                // set to violate, and an empty default emits no DDL
876                // `DEFAULT` at all.
877                if field.choices.is_empty()
878                    || field.default.is_empty()
879                    || field.choices.contains(&field.default)
880                {
881                    continue;
882                }
883                let hint = if field.default.contains("::") {
884                    // `Foo::Bar` → `bar`; choices are typically declared
885                    // with `rename_all = "lowercase"`, so lower the tail
886                    // before checking for a match.
887                    let suggested = field
888                        .default
889                        .rsplit("::")
890                        .next()
891                        .unwrap_or(&field.default)
892                        .to_lowercase();
893                    if field.choices.contains(&suggested) {
894                        format!(
895                            "Did you mean the DB literal `{suggested}`? Choices defaults are \
896                             the stored value (e.g. `\"draft\"`), not the Rust enum path \
897                             (`\"PostStatus::Draft\"`)."
898                        )
899                    } else {
900                        format!(
901                            "Set the default to one of the stored values: [{}].",
902                            field.choices.join(", "),
903                        )
904                    }
905                } else {
906                    format!(
907                        "Set the default to one of the stored values: [{}].",
908                        field.choices.join(", "),
909                    )
910                };
911                // Leak the owned strings into the finding's
912                // &'static-typed location — the walk runs once at boot,
913                // matching the storage check's pattern.
914                findings.push(SystemCheckFinding {
915                    check_id: "field.choices_default",
916                    severity: Severity::Error,
917                    location: CheckLocation::Field {
918                        plugin: Box::leak(plugin.clone().into_boxed_str()),
919                        model: Box::leak(model.name.clone().into_boxed_str()),
920                        field: Box::leak(field.name.clone().into_boxed_str()),
921                    },
922                    message: format!(
923                        "Model `{plugin}::{}` field `{}` has default `{}` which is not one \
924                         of its choices: [{}].",
925                        model.name,
926                        field.name,
927                        field.default,
928                        field.choices.join(", "),
929                    ),
930                    hint: Some(hint),
931                });
932            }
933        }
934    }
935    findings
936}
937
938/// Warn when `AuthPlugin` or `SessionsPlugin` is registered but
939/// `SecurityPlugin` is NOT.
940///
941/// An app that handles authenticated or session traffic with no
942/// `SecurityPlugin` has **no CSRF protection and no hardening headers**
943/// (CSP, Strict-Transport-Security, X-Frame-Options, etc.) — an
944/// easy-to-miss footgun. The check is a **Warning** (boot continues)
945/// because some apps legitimately handle CSRF through other means (a
946/// reverse-proxy header, a separate middleware, or a custom plugin).
947///
948/// Gaps2 #25 (scaffold-independent half): the scaffold half that auto-
949/// mounts `SecurityPlugin` in `umbral startproject` is deferred until the
950/// #8 scaffold lands.
951fn plugin_security_missing(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
952    let names = ctx.registered_plugin_names;
953    let has_auth = names.contains(&"auth");
954    let has_sessions = names.contains(&"sessions");
955    if !(has_auth || has_sessions) {
956        // Neither auth nor sessions — nothing to warn about.
957        return Vec::new();
958    }
959    if names.contains(&"security") {
960        // SecurityPlugin is present — all good.
961        return Vec::new();
962    }
963    let who = match (has_auth, has_sessions) {
964        (true, true) => "AuthPlugin and SessionsPlugin are",
965        (true, false) => "AuthPlugin is",
966        (false, true) => "SessionsPlugin is",
967        (false, false) => unreachable!(),
968    };
969    vec![SystemCheckFinding {
970        check_id: "plugin.security_missing",
971        severity: Severity::Warning,
972        location: CheckLocation::Settings,
973        message: format!(
974            "{who} mounted without SecurityPlugin — requests have no CSRF \
975             protection or security headers (CSP, HSTS, X-Frame-Options, …). \
976             Add `.plugin(SecurityPlugin::new())` to your App builder, or \
977             handle CSRF / headers through another mechanism.",
978        ),
979        hint: Some(
980            "add `.plugin(umbral_security::SecurityPlugin::new())` to your \
981             `App::builder()` call."
982                .to_string(),
983        ),
984    }]
985}
986
987/// True for `SqlType` variants that only work on Postgres. Phase 4.1
988/// added `Array(_)`; Phase 4.4 adds `Inet`, `Cidr`, `MacAddr`. Future
989/// Postgres-only types (HStore, FullTextSearch) get added to this
990/// match.
991fn is_postgres_only(ty: crate::orm::SqlType) -> bool {
992    use crate::orm::SqlType;
993    matches!(
994        ty,
995        SqlType::Array(_)
996            | SqlType::Inet
997            | SqlType::Cidr
998            | SqlType::MacAddr
999            // gaps2 #70: text-backed Postgres types (XML / LTREE /
1000            // BIT VARYING) have no SQLite equivalent; the boot check
1001            // rejects them on SQLite the same way as the network types.
1002            | SqlType::Xml
1003            | SqlType::Ltree
1004            | SqlType::Bit
1005            | SqlType::FullText
1006            // BUG-10: sqlx's `rust_decimal` Encode/Decode is
1007            // Postgres-only. SQLite has no native NUMERIC type;
1008            // any model with a Decimal column fails the boot
1009            // check the same way Array does.
1010            | SqlType::Decimal
1011            | SqlType::DecimalN(_)
1012            // BigDecimal is Postgres-only for the same reason — no
1013            // arbitrary-precision numeric codec on SQLite.
1014            | SqlType::BigDecimal
1015            // PostGIS spatial types — SQLite has no geometry/geography.
1016            | SqlType::Geometry(_)
1017            | SqlType::Geography(_)
1018    )
1019}
1020
1021/// Run every check in `checks` against `ctx`, accumulate findings, and
1022/// partition into errors vs warnings. Used by `AppBuilder::build()`
1023/// phase 4 and by tests.
1024///
1025/// Returns the full findings list; callers decide what to do with the
1026/// Error-severity entries (the builder turns them into
1027/// `BuildError::SystemCheckFailed`).
1028pub fn run_all(ctx: &CheckContext<'_>, checks: &[SystemCheck]) -> Vec<SystemCheckFinding> {
1029    let mut findings = Vec::new();
1030    for check in checks {
1031        findings.extend((check.run)(ctx));
1032    }
1033    findings
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::{
1039        allowed_hosts_has_wildcard, host_validation_unenforced, is_loopback_bind,
1040        is_sqlite_in_prod, prod_secret_key_error, redact_url_userinfo,
1041    };
1042    use crate::settings::Environment;
1043
1044    #[test]
1045    fn prod_rejects_weak_and_default_secret_keys() {
1046        // The insecure dev default is rejected in Prod (existing behaviour).
1047        assert!(
1048            prod_secret_key_error(&Environment::Prod, "umbral-insecure-dev-key-change-me")
1049                .is_some()
1050        );
1051        // A short non-default key is ALSO rejected in Prod (audit_2 H15).
1052        assert!(
1053            prod_secret_key_error(&Environment::Prod, "x").is_some(),
1054            "a trivially short secret_key must be rejected in Prod"
1055        );
1056        // A long random key passes.
1057        assert!(
1058            prod_secret_key_error(&Environment::Prod, "0123456789abcdef0123456789abcdef0123")
1059                .is_none()
1060        );
1061        // Outside Prod, nothing is enforced here (dev convenience).
1062        assert!(prod_secret_key_error(&Environment::Dev, "x").is_none());
1063    }
1064
1065    #[test]
1066    fn loopback_binds_are_recognised() {
1067        assert!(is_loopback_bind("127.0.0.1:8000"));
1068        assert!(is_loopback_bind("localhost:3000"));
1069        assert!(is_loopback_bind("[::1]:8080"));
1070        assert!(is_loopback_bind(":8000")); // host omitted → local
1071        // audit_2 findings #16: a bare unbracketed IPv6 loopback used to be
1072        // mangled by `rsplit(':')` into host `::` and misread as public.
1073        assert!(is_loopback_bind("::1"));
1074        assert!(is_loopback_bind("127.0.0.1"));
1075        assert!(!is_loopback_bind("0.0.0.0:8000"));
1076        assert!(!is_loopback_bind("192.168.1.10:8000"));
1077        assert!(!is_loopback_bind("[2001:db8::1]:8000"));
1078    }
1079
1080    #[test]
1081    fn host_validation_warns_only_off_prod_and_non_loopback() {
1082        // Non-loopback + not Prod → unenforced (warn).
1083        assert!(host_validation_unenforced(
1084            &Environment::Dev,
1085            "0.0.0.0:8000"
1086        ));
1087        // Prod enforces regardless of bind.
1088        assert!(!host_validation_unenforced(
1089            &Environment::Prod,
1090            "0.0.0.0:8000"
1091        ));
1092        // Loopback bind is not network-reachable with a forged Host.
1093        assert!(!host_validation_unenforced(
1094            &Environment::Dev,
1095            "127.0.0.1:8000"
1096        ));
1097    }
1098
1099    #[test]
1100    fn wildcard_allowed_hosts_flagged_only_in_prod() {
1101        let with_star = vec!["example.com".to_string(), "*".to_string()];
1102        let no_star = vec!["example.com".to_string()];
1103        // Prod + "*" → flagged.
1104        assert!(allowed_hosts_has_wildcard(&Environment::Prod, &with_star));
1105        // A padded wildcard still trips it.
1106        assert!(allowed_hosts_has_wildcard(
1107            &Environment::Prod,
1108            &[" * ".to_string()]
1109        ));
1110        // No wildcard → clean.
1111        assert!(!allowed_hosts_has_wildcard(&Environment::Prod, &no_star));
1112        // Outside Prod the guard isn't enforced anyway → no warning.
1113        assert!(!allowed_hosts_has_wildcard(&Environment::Dev, &with_star));
1114    }
1115
1116    #[test]
1117    fn sqlite_in_prod_flagged() {
1118        assert!(is_sqlite_in_prod(&Environment::Prod, "sqlite::memory:"));
1119        assert!(is_sqlite_in_prod(&Environment::Prod, "sqlite://app.db"));
1120        // Postgres in Prod is the happy path.
1121        assert!(!is_sqlite_in_prod(
1122            &Environment::Prod,
1123            "postgres://host/app"
1124        ));
1125        // SQLite outside Prod is expected (tests / local dev).
1126        assert!(!is_sqlite_in_prod(&Environment::Dev, "sqlite::memory:"));
1127    }
1128
1129    #[test]
1130    fn redact_url_userinfo_masks_password() {
1131        assert_eq!(
1132            redact_url_userinfo("postgres://u:p@host/db"),
1133            "postgres://***@host/db"
1134        );
1135        assert_eq!(redact_url_userinfo("sqlite::memory:"), "sqlite::memory:");
1136    }
1137}