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}