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: "settings.required",
176            run: settings_required,
177        },
178        SystemCheck {
179            id: "settings.allowed_hosts",
180            run: settings_allowed_hosts,
181        },
182        SystemCheck {
183            id: "settings.allowed_hosts_wildcard",
184            run: settings_allowed_hosts_wildcard,
185        },
186        SystemCheck {
187            id: "settings.sqlite_in_prod",
188            run: settings_sqlite_in_prod,
189        },
190        SystemCheck {
191            id: "settings.host_validation",
192            run: settings_host_validation,
193        },
194        SystemCheck {
195            id: "settings.log_level",
196            run: settings_log_level,
197        },
198        SystemCheck {
199            id: "backend.url_scheme.matches_active_backend",
200            run: backend_url_scheme_matches_active_backend,
201        },
202        SystemCheck {
203            id: "field.backend",
204            run: field_backend,
205        },
206        SystemCheck {
207            id: "field.storage_backend",
208            run: field_storage_backend,
209        },
210        SystemCheck {
211            id: "field.choices_default",
212            run: field_choices_default,
213        },
214        SystemCheck {
215            id: "field.case_insensitive.sqlite_ascii",
216            run: field_case_insensitive_sqlite_ascii,
217        },
218        SystemCheck {
219            id: "plugin.security_missing",
220            run: plugin_security_missing,
221        },
222    ]
223}
224
225/// Verify that `secret_key` is not the insecure dev default. Two
226/// layers:
227///
228/// 1. **Hard error in `Environment::Prod`** — the original check.
229///    Blocks boot when the operator self-identifies as production.
230/// 2. **Warning when the bind address looks public** — defense in
231///    depth for the operator who forgot to set
232///    `UMBRAL_ENVIRONMENT=Prod`. If `bind_addr` isn't `127.0.0.1` or
233///    `localhost`, the process is likely serving real network
234///    traffic, and the insecure dev key is dangerous regardless of
235///    the declared environment.
236///
237/// The boot-blocking error is intentionally reserved for explicit
238/// production declarations — surprising people with a build failure
239/// because they bound to `0.0.0.0` in a homelab test would be worse
240/// than the warning. The warning is the visible nudge.
241fn settings_required(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
242    let mut findings = Vec::new();
243    let insecure = ctx.settings.secret_key == INSECURE_DEV_SECRET_KEY;
244    if let Some(message) =
245        prod_secret_key_error(&ctx.settings.environment, &ctx.settings.secret_key)
246    {
247        findings.push(SystemCheckFinding {
248            check_id: "settings.required",
249            severity: Severity::Error,
250            location: CheckLocation::Settings,
251            message,
252            hint: Some("set a long, random UMBRAL_SECRET_KEY (>= 32 chars) in your production env, or change `secret_key` in umbral.toml.".to_string()),
253        });
254        return findings;
255    }
256    // The default for Environment is Dev, so an operator who never
257    // sets UMBRAL_ENVIRONMENT slips past the strict check above. Add a
258    // bind-address heuristic: if we're binding to something other than
259    // loopback, treat it as likely-public and warn.
260    if insecure && !is_loopback_bind(&ctx.settings.bind_addr) {
261        findings.push(SystemCheckFinding {
262            check_id: "settings.required",
263            severity: Severity::Warning,
264            location: CheckLocation::Settings,
265            message: format!(
266                "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.",
267                ctx.settings.bind_addr,
268            ),
269            hint: Some("set UMBRAL_SECRET_KEY, or restrict bind_addr to 127.0.0.1 for local dev.".to_string()),
270        });
271    }
272    findings
273}
274
275/// Warn when the server binds a non-loopback address but Host-header
276/// validation isn't enforced. `App::build` only mounts the
277/// `allowed_hosts` guard under [`Environment::Prod`] (see
278/// `app.rs`); a deployment that binds `0.0.0.0` while still flagged
279/// `Dev` therefore accepts *any* `Host` header — the classic vector
280/// for cache-poisoning and poisoned password-reset links.
281///
282/// The Prod path already enforces, so this only fires outside Prod,
283/// and only on a non-loopback bind (a local `127.0.0.1` dev server is
284/// not reachable with a forged Host from the network). It's a warning,
285/// not a boot-blocking error, for the same reason the insecure-key
286/// non-loopback case is: surprising a homelab test with a hard failure
287/// would be worse than the nudge.
288fn settings_host_validation(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
289    if !host_validation_unenforced(&ctx.settings.environment, &ctx.settings.bind_addr) {
290        return Vec::new();
291    }
292    vec![SystemCheckFinding {
293        check_id: "settings.host_validation",
294        severity: Severity::Warning,
295        location: CheckLocation::Settings,
296        message: format!(
297            "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).",
298            ctx.settings.bind_addr,
299        ),
300        hint: Some(
301            "set UMBRAL_ENVIRONMENT=Prod (enforces allowed_hosts), or bind 127.0.0.1 for local dev."
302                .to_string(),
303        ),
304    }]
305}
306
307/// Pure predicate behind [`settings_host_validation`]: Host validation
308/// is unenforced when we're *not* in Prod yet bound to a non-loopback
309/// address. Split out so it's testable without constructing a full
310/// [`CheckContext`] (which needs a live backend).
311fn host_validation_unenforced(environment: &Environment, bind_addr: &str) -> bool {
312    !matches!(environment, Environment::Prod) && !is_loopback_bind(bind_addr)
313}
314
315/// True when `bind_addr` parses as the loopback interface — i.e.
316/// `127.0.0.1`, `::1`, or `localhost`. Anything else is treated as
317/// likely public-facing for the secret_key defence-in-depth check.
318fn is_loopback_bind(bind_addr: &str) -> bool {
319    use std::net::{IpAddr, SocketAddr};
320    // Prefer real address parsing so IPv6 classifies correctly. `rsplit(':')`
321    // alone mangles a bare `::1` into host `::` (each colon is a candidate
322    // separator), misclassifying loopback as public (audit_2 findings #16). A
323    // full `SocketAddr` parse handles `[::1]:8000` / `127.0.0.1:8000`; a bare
324    // `IpAddr` parse handles `::1` / `127.0.0.1` with no port.
325    if let Ok(sa) = bind_addr.parse::<SocketAddr>() {
326        return sa.ip().is_loopback();
327    }
328    if let Ok(ip) = bind_addr.parse::<IpAddr>() {
329        return ip.is_loopback();
330    }
331    // Fall back to host-string inspection for `host:port` / `localhost` forms
332    // that aren't parseable IPs.
333    let host = bind_addr
334        .rsplit_once(':')
335        .map(|(host, _)| host)
336        .unwrap_or(bind_addr)
337        .trim_start_matches('[')
338        .trim_end_matches(']');
339    host == "127.0.0.1" || host == "::1" || host == "localhost" || host.is_empty()
340}
341
342/// Warn when `allowed_hosts` is still the dev default in
343/// `Environment::Prod`. A real prod app almost never serves only
344/// loopback; logging this gives the operator a nudge while letting the
345/// build proceed.
346fn settings_allowed_hosts(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
347    let mut findings = Vec::new();
348    if matches!(ctx.settings.environment, Environment::Prod)
349        && ctx.settings.allowed_hosts.len() == DEFAULT_ALLOWED_HOSTS.len()
350        && ctx
351            .settings
352            .allowed_hosts
353            .iter()
354            .zip(DEFAULT_ALLOWED_HOSTS.iter())
355            .all(|(a, b)| a == b)
356    {
357        findings.push(SystemCheckFinding {
358            check_id: "settings.allowed_hosts",
359            severity: Severity::Warning,
360            location: CheckLocation::Settings,
361            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(),
362            hint: Some("set UMBRAL_ALLOWED_HOSTS or `allowed_hosts` in umbral.toml to the hostnames this app actually serves.".to_string()),
363        });
364    }
365    findings
366}
367
368/// Warn when `allowed_hosts` contains the `"*"` wildcard in
369/// `Environment::Prod` (audit_2 core-app-config #13). A wildcard makes the
370/// Prod-only Host-header guard accept *any* Host, silently defeating the very
371/// control it enforces — the cache-poisoning / poisoned-reset-link vector the
372/// guard exists to close. It's a Warning (some apps front the app with a proxy
373/// that already pins Host), but an explicit, deliberate downgrade should be
374/// visible in the boot log.
375fn settings_allowed_hosts_wildcard(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
376    if !allowed_hosts_has_wildcard(&ctx.settings.environment, &ctx.settings.allowed_hosts) {
377        return Vec::new();
378    }
379    vec![SystemCheckFinding {
380        check_id: "settings.allowed_hosts_wildcard",
381        severity: Severity::Warning,
382        location: CheckLocation::Settings,
383        message: "Settings.allowed_hosts contains the \"*\" wildcard in Environment::Prod — the \
384             Host-header guard accepts ANY Host, defeating host validation (cache-poisoning / \
385             poisoned-reset-link risk)."
386            .to_string(),
387        hint: Some(
388            "list the exact hostnames this app serves in UMBRAL_ALLOWED_HOSTS instead of \"*\"."
389                .to_string(),
390        ),
391    }]
392}
393
394/// Pure predicate behind [`settings_allowed_hosts_wildcard`]: a `"*"` entry
395/// while in Prod. Split out so it's testable without a live backend.
396fn allowed_hosts_has_wildcard(environment: &Environment, allowed_hosts: &[String]) -> bool {
397    matches!(environment, Environment::Prod) && allowed_hosts.iter().any(|h| h.trim() == "*")
398}
399
400/// Warn when the app runs on SQLite in `Environment::Prod` (audit_2
401/// core-app-config #13). SQLite is the framework's test/local-dev backend
402/// (Postgres-first per the design principles); a production deployment on
403/// SQLite gets a single-writer lock, no network concurrency, and no
404/// replica/pooling story. It's a Warning, not an error — small single-node
405/// apps legitimately ship on SQLite — but it should be a conscious choice, not
406/// a forgotten `sqlite::memory:` default.
407fn settings_sqlite_in_prod(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
408    if !is_sqlite_in_prod(&ctx.settings.environment, &ctx.settings.database_url) {
409        return Vec::new();
410    }
411    vec![SystemCheckFinding {
412        check_id: "settings.sqlite_in_prod",
413        severity: Severity::Warning,
414        location: CheckLocation::Settings,
415        message: format!(
416            "database_url `{}` is SQLite in Environment::Prod. SQLite is the dev/test backend \
417             (single writer, no network concurrency); production traffic wants Postgres.",
418            redact_url_userinfo(&ctx.settings.database_url),
419        ),
420        hint: Some(
421            "set UMBRAL_DATABASE_URL to a `postgres://...` URL for production, or keep SQLite \
422             deliberately for a small single-node deployment."
423                .to_string(),
424        ),
425    }]
426}
427
428/// Pure predicate behind [`settings_sqlite_in_prod`]: a `sqlite`-scheme URL
429/// (including the `sqlite::memory:` default) while in Prod. Split out so it's
430/// testable without a live backend.
431fn is_sqlite_in_prod(environment: &Environment, database_url: &str) -> bool {
432    matches!(environment, Environment::Prod)
433        && database_url
434            .split_once(':')
435            .map(|(scheme, _)| scheme.eq_ignore_ascii_case("sqlite"))
436            .unwrap_or(false)
437}
438
439/// Mask the userinfo of a connection URL for a boot-check message so a
440/// password embedded in `database_url` never lands in the log. Mirrors
441/// `crate::settings`'s own redaction; kept local so `check.rs` needn't reach
442/// into a sibling's private helper.
443fn redact_url_userinfo(url: &str) -> String {
444    let Some(scheme_end) = url.find("://") else {
445        return url.to_string();
446    };
447    let after = scheme_end + 3;
448    let authority_end = url[after..]
449        .find(['/', '?', '#'])
450        .map(|i| after + i)
451        .unwrap_or(url.len());
452    match url[after..authority_end].find('@') {
453        Some(at) => format!("{}***{}", &url[..after], &url[after + at..]),
454        None => url.to_string(),
455    }
456}
457
458/// Warn when `log_level` is `debug` or `trace` in `Environment::Prod`.
459/// Verbose logging in production leaks internals into stdout and
460/// usually means a debug session was left on by accident.
461fn settings_log_level(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
462    let mut findings = Vec::new();
463    let level = ctx.settings.log_level.to_ascii_lowercase();
464    if matches!(ctx.settings.environment, Environment::Prod)
465        && (level == "debug" || level == "trace")
466    {
467        findings.push(SystemCheckFinding {
468            check_id: "settings.log_level",
469            severity: Severity::Warning,
470            location: CheckLocation::Settings,
471            message: format!(
472                "Settings.log_level is \"{}\" in Environment::Prod. Verbose logging in production leaks internals and adds noise.",
473                ctx.settings.log_level
474            ),
475            hint: Some("set UMBRAL_LOG_LEVEL to \"info\", \"warn\", or \"error\" for production deployments.".to_string()),
476        });
477    }
478    findings
479}
480
481/// Defensive invariant: the URL scheme in `database_url` should match
482/// the active backend's `name()`. Phase 2 picks the backend from the
483/// URL, so the two agree by construction today; this check exists so a
484/// future codepath that sets the backend manually can't silently drift.
485fn backend_url_scheme_matches_active_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
486    let mut findings = Vec::new();
487    let scheme = ctx
488        .settings
489        .database_url
490        .split_once(':')
491        .map(|(s, _)| s)
492        .unwrap_or("");
493    let expected_backend = match scheme {
494        "postgres" | "postgresql" => Some("postgres"),
495        "sqlite" => Some("sqlite"),
496        _ => None,
497    };
498    if let Some(expected) = expected_backend {
499        let active = ctx.backend.name();
500        if expected != active {
501            findings.push(SystemCheckFinding {
502                check_id: "backend.url_scheme.matches_active_backend",
503                severity: Severity::Error,
504                location: CheckLocation::Settings,
505                message: format!(
506                    "Settings.database_url scheme \"{scheme}\" implies backend \"{expected}\", but the active backend is \"{active}\"."
507                ),
508                hint: Some("the URL and the active backend must agree; fix `database_url` in umbral.toml or whichever codepath overrode the backend.".to_string()),
509            });
510        }
511    }
512    findings
513}
514
515/// Walk every registered model and fail at boot when a field's type
516/// is incompatible with the active backend.
517///
518/// Phase 4.1 ships exactly one gated type: `SqlType::Array(_)`, which
519/// only works on Postgres. The check matches on the `Column::ty`
520/// stored in the migrate registry directly, rather than walking back
521/// to `Model::FIELDS` for the `supported_backends` slice (the latter
522/// isn't carried on `migrate::Column`). When the next Postgres-only
523/// `SqlType` variant lands (HStore, FullTextSearch, etc.), it gets
524/// added to the `is_postgres_only` match below.
525///
526/// **Error**, not Warning: a field rendered against the wrong backend
527/// produces incorrect DDL or a runtime panic deep inside `bind_value`.
528/// Boot-time failure with a clear message is the right behaviour.
529fn field_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
530    let mut findings = Vec::new();
531    let active = ctx.backend.name();
532    if active == "postgres" {
533        // No Postgres-only type is rejected on Postgres; the SQLite
534        // side does the rejecting. Early return keeps the registry
535        // walk out of the hot path on Postgres boots.
536        return findings;
537    }
538    // Low-level tests that drive `run_all` without booting an App
539    // never publish the model registry; the check would panic on
540    // `registered_plugins()`. Skip silently — there are no models to
541    // walk anyway.
542    if !crate::migrate::is_initialised() {
543        return findings;
544    }
545
546    for plugin in crate::migrate::registered_plugins() {
547        for model in crate::migrate::models_for_plugin(&plugin) {
548            for field in &model.fields {
549                // IMP-5: per-field backend gate via
550                // `#[umbral(backend = "postgres")]`. When the slice
551                // is non-empty and the active backend isn't listed,
552                // reject at boot with a clear message. The
553                // hardcoded `is_postgres_only` branch below remains
554                // for types the framework knows about; the
555                // declared-list path covers user-facing attribute
556                // shape.
557                if !field.supported_backends.is_empty()
558                    && !field.supported_backends.iter().any(|b| b == active)
559                {
560                    findings.push(SystemCheckFinding {
561                        check_id: "field.backend",
562                        severity: Severity::Error,
563                        location: CheckLocation::Settings,
564                        message: format!(
565                            "Field `{plugin}::{}::{}` declares `#[umbral(backend = ...)]` \
566                             as {:?}, but the active backend is `{active}`.",
567                            model.name, field.name, field.supported_backends,
568                        ),
569                        hint: Some(format!(
570                            "switch UMBRAL_DATABASE_URL to a backend matching one of \
571                             {:?}, or drop the `backend` attribute and pick a portable \
572                             field type.",
573                            field.supported_backends,
574                        )),
575                    });
576                    continue;
577                }
578                if is_postgres_only(field.ty) {
579                    findings.push(SystemCheckFinding {
580                        check_id: "field.backend",
581                        severity: Severity::Error,
582                        location: CheckLocation::Settings,
583                        message: format!(
584                            "Field `{plugin}::{}::{}` has type {:?} which is Postgres-only, but the active backend is `{active}`.",
585                            model.name, field.name, field.ty,
586                        ),
587                        hint: Some(
588                            "switch UMBRAL_DATABASE_URL to a `postgres://...` URL, \
589                             or change the field to a portable type — \
590                             `serde_json::Value` (SqlType::Json) is the closest \
591                             portable analogue to an array."
592                                .to_string(),
593                        ),
594                    });
595                }
596            }
597        }
598    }
599    findings
600}
601
602/// Fail at boot when a model declares a `FileField` / `ImageField`
603/// (detected by the column's `widget` being `"file"` or `"image"`) but
604/// no registered plugin provides a [`Storage`](crate::storage::Storage)
605/// backend.
606///
607/// **Why the capability flag, not the ambient `storage_opt()`:** a
608/// `Storage` backend is registered in `Plugin::on_ready`, which runs
609/// *after* the system-check phase (see `App::build`'s phase ordering).
610/// So at check time `crate::storage::storage_opt()` is still `None` even
611/// when `StoragePlugin` is wired and *will* register a backend a moment
612/// later. Checking the ambient here would false-positive on every app
613/// that uses media. Instead we read `ctx.provides_storage`, which
614/// `App::build` computes from the sorted plugin list's
615/// `Plugin::provides_storage()` flags — the *declared capability*, which
616/// is knowable at check time.
617///
618/// **Error**, not Warning: a file/image field with no backend means
619/// `FileField::url` silently falls back to the raw key, producing broken
620/// `<img src>` / download links in production. Failing the build with a
621/// clear fix is the right behaviour.
622fn field_storage_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
623    let mut findings = Vec::new();
624    // A backend is (or will be) registered — nothing to check.
625    if ctx.provides_storage {
626        return findings;
627    }
628    // Low-level tests that drive `run_all` without booting an App never
629    // publish the model registry; skip silently (there are no models to
630    // walk anyway, same guard as `field_backend`).
631    if !crate::migrate::is_initialised() {
632        return findings;
633    }
634    for plugin in crate::migrate::registered_plugins() {
635        for model in crate::migrate::models_for_plugin(&plugin) {
636            for field in &model.fields {
637                let is_file_field = matches!(field.widget.as_deref(), Some("file") | Some("image"));
638                if !is_file_field {
639                    continue;
640                }
641                // Leak the owned strings into the finding's
642                // &'static-typed location. The walk runs once at boot, so
643                // the small leak is acceptable and matches the
644                // location-string contract (Field carries &'static str).
645                findings.push(SystemCheckFinding {
646                    check_id: "field.storage_backend",
647                    severity: Severity::Error,
648                    location: CheckLocation::Field {
649                        plugin: Box::leak(plugin.clone().into_boxed_str()),
650                        model: Box::leak(model.name.clone().into_boxed_str()),
651                        field: Box::leak(field.name.clone().into_boxed_str()),
652                    },
653                    message: format!(
654                        "Model `{plugin}::{}` field `{}` declares a file/image field, \
655                         but no Storage backend is registered.",
656                        model.name, field.name,
657                    ),
658                    hint: Some(
659                        "add `StoragePlugin` to your app (it registers a filesystem Storage \
660                         backend), or call `umbral::storage::set_storage(...)` before \
661                         `App::build()` to wire a custom backend."
662                            .to_string(),
663                    ),
664                });
665            }
666        }
667    }
668    findings
669}
670
671/// Walk every registered model and fail at boot when a `choices`
672/// column's declared default isn't one of the column's choices.
673///
674/// **Why this exists (gaps2 #32):** a choices field's default lands
675/// verbatim in DDL (`migrate.rs`'s `def.default(col.default.clone())`),
676/// so writing `#[umbral(default = "PostStatus::Draft")]` — the Rust enum
677/// *path* instead of the stored DB literal `"draft"` — ships a broken
678/// schema. Postgres rejects the row at insert via the `CHECK (col IN
679/// (...))` constraint; SQLite stores the undecodable text and errors on
680/// the next `SELECT` when the `ChoiceField` decoder can't map it back.
681/// Per the "backend mismatches caught at boot" principle, this surfaces
682/// the mistake at build time with a clear message instead of in prod.
683///
684/// The check works off `Column.choices`, which already holds the DB
685/// values (`FieldSpec::choices`), so `choices` *is* the allowed set —
686/// no need to reach for `ChoiceField::VALUES`. When the bad default
687/// contains `::` (the tell-tale of a pasted Rust enum path), we lower
688/// the part after the last `::` and, if that matches a real choice,
689/// emit a did-you-mean for the stored literal.
690///
691/// **Error**, not Warning: the DDL is wrong and the table is unusable.
692/// gaps3 #35 — warn when `#[umbral(case_insensitive)]` is used on SQLite.
693///
694/// SQLite's `COLLATE NOCASE` folds only ASCII `A–Z`, so a case-insensitive
695/// UNIQUE / lookup won't treat, say, `Å` and `å` (or Turkish `İ`/`i`) as equal.
696/// Postgres `citext` folds per the database collation, so the check is a no-op
697/// there. A warning (not an error): the column still works for the ASCII names
698/// that are the overwhelming case; the developer just needs to know the limit.
699fn field_case_insensitive_sqlite_ascii(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
700    let mut findings = Vec::new();
701    if ctx.backend.name() != "sqlite" {
702        return findings;
703    }
704    if !crate::migrate::is_initialised() {
705        return findings;
706    }
707    for plugin in crate::migrate::registered_plugins() {
708        for model in crate::migrate::models_for_plugin(&plugin) {
709            for field in &model.fields {
710                if !field.case_insensitive {
711                    continue;
712                }
713                findings.push(SystemCheckFinding {
714                    check_id: "field.case_insensitive.sqlite_ascii",
715                    severity: Severity::Warning,
716                    location: CheckLocation::Settings,
717                    message: format!(
718                        "Field `{plugin}::{}::{}` is `#[umbral(case_insensitive)]`, but the active \
719                         backend is SQLite, whose `COLLATE NOCASE` folds ASCII A–Z only — \
720                         non-ASCII letters are compared case-sensitively.",
721                        model.name, field.name,
722                    ),
723                    hint: Some(
724                        "Fine for ASCII usernames/emails/slugs. If you need Unicode-correct \
725                         case-folding, use Postgres (citext folds per the DB collation), or \
726                         normalize with `#[umbral(lowercase)]` (Rust's to_lowercase is \
727                         Unicode-aware) when preserving the original casing isn't required."
728                            .to_string(),
729                    ),
730                });
731            }
732        }
733    }
734    findings
735}
736
737fn field_choices_default(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
738    let mut findings = Vec::new();
739    // Low-level tests that drive `run_all` without booting an App never
740    // publish the model registry; skip silently (same guard as the
741    // other model-walking checks).
742    if !crate::migrate::is_initialised() {
743        return findings;
744    }
745    for plugin in crate::migrate::registered_plugins() {
746        for model in crate::migrate::models_for_plugin(&plugin) {
747            for field in &model.fields {
748                // Only choices columns with an explicit default can be
749                // wrong this way: a non-choices column has no allowed
750                // set to violate, and an empty default emits no DDL
751                // `DEFAULT` at all.
752                if field.choices.is_empty()
753                    || field.default.is_empty()
754                    || field.choices.contains(&field.default)
755                {
756                    continue;
757                }
758                let hint = if field.default.contains("::") {
759                    // `Foo::Bar` → `bar`; choices are typically declared
760                    // with `rename_all = "lowercase"`, so lower the tail
761                    // before checking for a match.
762                    let suggested = field
763                        .default
764                        .rsplit("::")
765                        .next()
766                        .unwrap_or(&field.default)
767                        .to_lowercase();
768                    if field.choices.contains(&suggested) {
769                        format!(
770                            "Did you mean the DB literal `{suggested}`? Choices defaults are \
771                             the stored value (e.g. `\"draft\"`), not the Rust enum path \
772                             (`\"PostStatus::Draft\"`)."
773                        )
774                    } else {
775                        format!(
776                            "Set the default to one of the stored values: [{}].",
777                            field.choices.join(", "),
778                        )
779                    }
780                } else {
781                    format!(
782                        "Set the default to one of the stored values: [{}].",
783                        field.choices.join(", "),
784                    )
785                };
786                // Leak the owned strings into the finding's
787                // &'static-typed location — the walk runs once at boot,
788                // matching the storage check's pattern.
789                findings.push(SystemCheckFinding {
790                    check_id: "field.choices_default",
791                    severity: Severity::Error,
792                    location: CheckLocation::Field {
793                        plugin: Box::leak(plugin.clone().into_boxed_str()),
794                        model: Box::leak(model.name.clone().into_boxed_str()),
795                        field: Box::leak(field.name.clone().into_boxed_str()),
796                    },
797                    message: format!(
798                        "Model `{plugin}::{}` field `{}` has default `{}` which is not one \
799                         of its choices: [{}].",
800                        model.name,
801                        field.name,
802                        field.default,
803                        field.choices.join(", "),
804                    ),
805                    hint: Some(hint),
806                });
807            }
808        }
809    }
810    findings
811}
812
813/// Warn when `AuthPlugin` or `SessionsPlugin` is registered but
814/// `SecurityPlugin` is NOT.
815///
816/// An app that handles authenticated or session traffic with no
817/// `SecurityPlugin` has **no CSRF protection and no hardening headers**
818/// (CSP, Strict-Transport-Security, X-Frame-Options, etc.) — an
819/// easy-to-miss footgun. The check is a **Warning** (boot continues)
820/// because some apps legitimately handle CSRF through other means (a
821/// reverse-proxy header, a separate middleware, or a custom plugin).
822///
823/// Gaps2 #25 (scaffold-independent half): the scaffold half that auto-
824/// mounts `SecurityPlugin` in `umbral startproject` is deferred until the
825/// #8 scaffold lands.
826fn plugin_security_missing(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
827    let names = ctx.registered_plugin_names;
828    let has_auth = names.contains(&"auth");
829    let has_sessions = names.contains(&"sessions");
830    if !(has_auth || has_sessions) {
831        // Neither auth nor sessions — nothing to warn about.
832        return Vec::new();
833    }
834    if names.contains(&"security") {
835        // SecurityPlugin is present — all good.
836        return Vec::new();
837    }
838    let who = match (has_auth, has_sessions) {
839        (true, true) => "AuthPlugin and SessionsPlugin are",
840        (true, false) => "AuthPlugin is",
841        (false, true) => "SessionsPlugin is",
842        (false, false) => unreachable!(),
843    };
844    vec![SystemCheckFinding {
845        check_id: "plugin.security_missing",
846        severity: Severity::Warning,
847        location: CheckLocation::Settings,
848        message: format!(
849            "{who} mounted without SecurityPlugin — requests have no CSRF \
850             protection or security headers (CSP, HSTS, X-Frame-Options, …). \
851             Add `.plugin(SecurityPlugin::new())` to your App builder, or \
852             handle CSRF / headers through another mechanism.",
853        ),
854        hint: Some(
855            "add `.plugin(umbral_security::SecurityPlugin::new())` to your \
856             `App::builder()` call."
857                .to_string(),
858        ),
859    }]
860}
861
862/// True for `SqlType` variants that only work on Postgres. Phase 4.1
863/// added `Array(_)`; Phase 4.4 adds `Inet`, `Cidr`, `MacAddr`. Future
864/// Postgres-only types (HStore, FullTextSearch) get added to this
865/// match.
866fn is_postgres_only(ty: crate::orm::SqlType) -> bool {
867    use crate::orm::SqlType;
868    matches!(
869        ty,
870        SqlType::Array(_)
871            | SqlType::Inet
872            | SqlType::Cidr
873            | SqlType::MacAddr
874            // gaps2 #70: text-backed Postgres types (XML / LTREE /
875            // BIT VARYING) have no SQLite equivalent; the boot check
876            // rejects them on SQLite the same way as the network types.
877            | SqlType::Xml
878            | SqlType::Ltree
879            | SqlType::Bit
880            | SqlType::FullText
881            // BUG-10: sqlx's `rust_decimal` Encode/Decode is
882            // Postgres-only. SQLite has no native NUMERIC type;
883            // any model with a Decimal column fails the boot
884            // check the same way Array does.
885            | SqlType::Decimal
886    )
887}
888
889/// Run every check in `checks` against `ctx`, accumulate findings, and
890/// partition into errors vs warnings. Used by `AppBuilder::build()`
891/// phase 4 and by tests.
892///
893/// Returns the full findings list; callers decide what to do with the
894/// Error-severity entries (the builder turns them into
895/// `BuildError::SystemCheckFailed`).
896pub fn run_all(ctx: &CheckContext<'_>, checks: &[SystemCheck]) -> Vec<SystemCheckFinding> {
897    let mut findings = Vec::new();
898    for check in checks {
899        findings.extend((check.run)(ctx));
900    }
901    findings
902}
903
904#[cfg(test)]
905mod tests {
906    use super::{
907        allowed_hosts_has_wildcard, host_validation_unenforced, is_loopback_bind,
908        is_sqlite_in_prod, prod_secret_key_error, redact_url_userinfo,
909    };
910    use crate::settings::Environment;
911
912    #[test]
913    fn prod_rejects_weak_and_default_secret_keys() {
914        // The insecure dev default is rejected in Prod (existing behaviour).
915        assert!(
916            prod_secret_key_error(&Environment::Prod, "umbral-insecure-dev-key-change-me")
917                .is_some()
918        );
919        // A short non-default key is ALSO rejected in Prod (audit_2 H15).
920        assert!(
921            prod_secret_key_error(&Environment::Prod, "x").is_some(),
922            "a trivially short secret_key must be rejected in Prod"
923        );
924        // A long random key passes.
925        assert!(
926            prod_secret_key_error(&Environment::Prod, "0123456789abcdef0123456789abcdef0123")
927                .is_none()
928        );
929        // Outside Prod, nothing is enforced here (dev convenience).
930        assert!(prod_secret_key_error(&Environment::Dev, "x").is_none());
931    }
932
933    #[test]
934    fn loopback_binds_are_recognised() {
935        assert!(is_loopback_bind("127.0.0.1:8000"));
936        assert!(is_loopback_bind("localhost:3000"));
937        assert!(is_loopback_bind("[::1]:8080"));
938        assert!(is_loopback_bind(":8000")); // host omitted → local
939        // audit_2 findings #16: a bare unbracketed IPv6 loopback used to be
940        // mangled by `rsplit(':')` into host `::` and misread as public.
941        assert!(is_loopback_bind("::1"));
942        assert!(is_loopback_bind("127.0.0.1"));
943        assert!(!is_loopback_bind("0.0.0.0:8000"));
944        assert!(!is_loopback_bind("192.168.1.10:8000"));
945        assert!(!is_loopback_bind("[2001:db8::1]:8000"));
946    }
947
948    #[test]
949    fn host_validation_warns_only_off_prod_and_non_loopback() {
950        // Non-loopback + not Prod → unenforced (warn).
951        assert!(host_validation_unenforced(
952            &Environment::Dev,
953            "0.0.0.0:8000"
954        ));
955        // Prod enforces regardless of bind.
956        assert!(!host_validation_unenforced(
957            &Environment::Prod,
958            "0.0.0.0:8000"
959        ));
960        // Loopback bind is not network-reachable with a forged Host.
961        assert!(!host_validation_unenforced(
962            &Environment::Dev,
963            "127.0.0.1:8000"
964        ));
965    }
966
967    #[test]
968    fn wildcard_allowed_hosts_flagged_only_in_prod() {
969        let with_star = vec!["example.com".to_string(), "*".to_string()];
970        let no_star = vec!["example.com".to_string()];
971        // Prod + "*" → flagged.
972        assert!(allowed_hosts_has_wildcard(&Environment::Prod, &with_star));
973        // A padded wildcard still trips it.
974        assert!(allowed_hosts_has_wildcard(
975            &Environment::Prod,
976            &[" * ".to_string()]
977        ));
978        // No wildcard → clean.
979        assert!(!allowed_hosts_has_wildcard(&Environment::Prod, &no_star));
980        // Outside Prod the guard isn't enforced anyway → no warning.
981        assert!(!allowed_hosts_has_wildcard(&Environment::Dev, &with_star));
982    }
983
984    #[test]
985    fn sqlite_in_prod_flagged() {
986        assert!(is_sqlite_in_prod(&Environment::Prod, "sqlite::memory:"));
987        assert!(is_sqlite_in_prod(&Environment::Prod, "sqlite://app.db"));
988        // Postgres in Prod is the happy path.
989        assert!(!is_sqlite_in_prod(
990            &Environment::Prod,
991            "postgres://host/app"
992        ));
993        // SQLite outside Prod is expected (tests / local dev).
994        assert!(!is_sqlite_in_prod(&Environment::Dev, "sqlite::memory:"));
995    }
996
997    #[test]
998    fn redact_url_userinfo_masks_password() {
999        assert_eq!(
1000            redact_url_userinfo("postgres://u:p@host/db"),
1001            "postgres://***@host/db"
1002        );
1003        assert_eq!(redact_url_userinfo("sqlite::memory:"), "sqlite::memory:");
1004    }
1005}