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}