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