umbral_core/errors.rs
1//! Custom 404 / 500 page helpers.
2//!
3//! Two pieces of [`AppBuilder`](crate::app::AppBuilder) state plug
4//! together with the existing templates engine to deliver the
5//! "drop a `404.html` in your templates dir" experience:
6//!
7//! - `not_found_template(name)` — installs a fallback that renders
8//! the named template with `{ path }` in scope and returns 404.
9//! - `server_error_template(name)` — wraps the router with a
10//! panic-catching tower-http layer that renders the named template
11//! on any handler panic and returns 500.
12//!
13//! Gap 35 extensions:
14//!
15//! - `on_server_error(hook)` — an opt-in hook that fires before the 500
16//! template is rendered. The closure receives the error message and the
17//! request path. Runs synchronously on the error path (panic or `Err`
18//! propagated as a 500). Cannot change the response; used for logging,
19//! Sentry dispatch, etc.
20//! - Default Tailwind 404/500 templates — shipped as embedded strings so
21//! they work without any `templates/` directory on disk. Used when the
22//! user hasn't set their own template name via the builder. Opt-out via
23//! `App::builder().disable_default_error_pages()`.
24//! - Dev-mode error detail — when `settings.environment == Dev`, the 500
25//! template receives an `error_chain` context variable listing the full
26//! `std::error::Error` source chain. In prod the variable is empty.
27//!
28//! Both the 404 and 500 fallbacks are opt-in. When unset (and default
29//! pages are disabled), the fallback returns plain-text "Not Found" and
30//! panics propagate axum-style (log + empty 500 body).
31//!
32//! The 404 path composes with [`SlashRedirect`](crate::slash::SlashRedirect)
33//! — if the redirect probe finds an alternate, it 308s; otherwise the
34//! configured not-found template renders. Users get one consistent
35//! 404 page across normal misses and slash-redirect dead-ends.
36
37use std::any::Any;
38
39use axum::body::Body;
40use axum::http::{Request, Response, StatusCode, header};
41use axum::response::IntoResponse;
42use minijinja::context;
43
44// ─── Embedded default templates ─────────────────────────────────────────────
45
46/// Default 404 page: centered, inline Tailwind utility classes. Degrades
47/// gracefully without Tailwind loaded — the page is functional even as
48/// unstyled HTML.
49pub const DEFAULT_404_HTML: &str = include_str!("templates/defaults/default_404.html");
50
51/// Default 500 page: same shape as the 404. In dev mode the template
52/// receives `error_display`, `error_chain` (vec of source strings), and
53/// `request_path` context variables that render into an expandable detail
54/// block. In prod those variables are empty strings / empty vecs.
55pub const DEFAULT_500_HTML: &str = include_str!("templates/defaults/default_500.html");
56
57// Template names used when registering the defaults into minijinja.
58// `pub` so integration tests can verify the constants without duplicating
59// the string literals.
60pub const DEFAULT_404_TEMPLATE_NAME: &str = "__umbral__/default_404.html";
61pub const DEFAULT_500_TEMPLATE_NAME: &str = "__umbral__/default_500.html";
62
63// ─── On-server-error hook type ───────────────────────────────────────────────
64
65/// Shared callback type for the `on_server_error` hook.
66///
67/// The hook fires on every 500 — both panics and handler errors that are
68/// turned into a 500 response — before the template is rendered.
69///
70/// Arguments:
71/// - `error_display`: the `Display` form of the error, or the stringified
72/// panic payload.
73/// - `request_path`: the URI path of the failing request.
74pub type ServerErrorHook = std::sync::Arc<dyn Fn(&str, &str) + Send + Sync + 'static>;
75
76// ─── Ambient default-pages flag ─────────────────────────────────────────────
77
78use std::sync::OnceLock;
79
80/// Whether the default error pages are enabled. Set during `App::build()`.
81/// `true` (default) — use the embedded templates when the user hasn't
82/// supplied their own. `false` — user called `.disable_default_error_pages()`.
83static DEFAULT_PAGES_ENABLED: OnceLock<bool> = OnceLock::new();
84
85/// Publish the default-pages flag. Called by `AppBuilder::build()` only.
86pub(crate) fn init_default_pages(enabled: bool) {
87 // Ignore the error if already set (e.g. two App builds in the same
88 // process in tests). The first caller wins, matching the OnceLock
89 // contract everywhere else in the framework.
90 let _ = DEFAULT_PAGES_ENABLED.set(enabled);
91}
92
93/// Return whether default pages are enabled.
94pub(crate) fn default_pages_enabled() -> bool {
95 // When called outside App::build() (unit tests that exercise render_*
96 // directly), default to true so the helpers behave like a real app.
97 *DEFAULT_PAGES_ENABLED.get().unwrap_or(&true)
98}
99
100// ─── 404 helpers ────────────────────────────────────────────────────────────
101
102/// Render the configured 404 template with `{ path }` in scope, or
103/// fall back to the plain-text response when no template is set or
104/// rendering fails.
105///
106/// When `template` is `None` and the default pages are enabled, the
107/// framework's own `default_404.html` is rendered instead. When
108/// default pages are disabled and no template name is set, returns
109/// plain "Not Found".
110///
111/// Used by:
112///
113/// - [`crate::slash::slash_redirect_fallback`] for the no-alternate
114/// branch.
115/// - The standalone not-found fallback installed when only
116/// `not_found_template` is set (no slash redirect).
117///
118/// The template gets the request path as `path` so it can render
119/// `The page {{ path }} doesn't exist.` without the user wiring
120/// extractors. Other request state isn't exposed yet — the v1 shape
121/// is intentionally narrow.
122pub fn render_not_found(template: Option<&str>, path: &str) -> Response<Body> {
123 // Resolve the effective template name:
124 // 1. User-supplied name takes highest priority.
125 // 2. Embedded default (registered as __umbral__/default_404.html) when
126 // default pages are enabled.
127 // 3. Plain-text fallback.
128 let effective_template = template.or_else(|| {
129 if default_pages_enabled() {
130 Some(DEFAULT_404_TEMPLATE_NAME)
131 } else {
132 None
133 }
134 });
135
136 // Derive Content-Type from whether render actually produced HTML.
137 // When the engine isn't initialised or the template fails to render,
138 // the fallback "Not Found" body is plaintext; it would be wrong to
139 // ship it as text/html.
140 //
141 // In dev mode, surface the registered-route registry so a
142 // developer who hits a typoed URL can see what's actually
143 // available. Production responses stay minimal — `dev_mode` is
144 // false there, so the template's `{% if dev_mode %}` block
145 // collapses to nothing.
146 let dev_mode = crate::settings::get_opt()
147 .map(|s| matches!(s.environment, crate::settings::Environment::Dev))
148 .unwrap_or(false);
149 let routes_ctx: Vec<minijinja::Value> = if dev_mode {
150 crate::routes::get()
151 .map(|reg| {
152 reg.by_plugin
153 .iter()
154 .filter(|(_, specs)| !specs.is_empty())
155 .map(|(plugin, specs)| {
156 // Pre-shape each route entry for the
157 // template's loop: a path string and a
158 // pre-joined method label. Pre-joining here
159 // lets the template render the badge with a
160 // single `{{ route.method_label }}` access
161 // instead of nesting another for-loop.
162 let routes: Vec<minijinja::Value> = specs
163 .iter()
164 .map(|s| {
165 let method_label = if s.methods.is_empty() {
166 "ANY".to_string()
167 } else {
168 s.methods.join("·")
169 };
170 minijinja::context! {
171 path => s.path.as_str(),
172 methods => s.methods.clone(),
173 method_label => method_label,
174 }
175 })
176 .collect();
177 minijinja::context! {
178 plugin => plugin.as_str(),
179 routes => routes,
180 }
181 })
182 .collect()
183 })
184 .unwrap_or_default()
185 } else {
186 Vec::new()
187 };
188 let ctx = context! {
189 path => path,
190 dev_mode => dev_mode,
191 routes_by_plugin => routes_ctx,
192 };
193 let (body, content_type) = effective_template
194 .and_then(|name| match crate::templates::render(name, &ctx) {
195 Ok(html) => Some(html),
196 // Falling back to plain text is intentional (no double-fault on
197 // the error path), but a broken error template should leave a
198 // trace rather than silently degrade.
199 Err(e) => {
200 tracing::warn!(
201 "error-page template `{name}` failed to render ({e}); \
202 falling back to plain text"
203 );
204 None
205 }
206 })
207 .map(|html| (html, "text/html; charset=utf-8"))
208 .unwrap_or_else(|| ("Not Found".to_string(), "text/plain; charset=utf-8"));
209
210 let mut response = Response::new(Body::from(body));
211 *response.status_mut() = StatusCode::NOT_FOUND;
212 response.headers_mut().insert(
213 header::CONTENT_TYPE,
214 content_type.parse().expect("valid content-type"),
215 );
216 response
217}
218
219/// Build an axum fallback handler that renders the configured 404
220/// template. Used when `not_found_template` is set but
221/// `slash_redirect` is `Off` — `App::build` skips the slash redirect
222/// path and installs this directly.
223pub fn not_found_fallback(
224 template: Option<String>,
225) -> impl Fn(
226 Request<Body>,
227) -> std::pin::Pin<Box<dyn std::future::Future<Output = Response<Body>> + Send>>
228+ Clone
229+ Send
230+ Sync
231+ 'static {
232 move |req: Request<Body>| {
233 let template = template.clone();
234 Box::pin(async move {
235 let path = req.uri().path().to_owned();
236 render_not_found(template.as_deref(), &path)
237 })
238 }
239}
240
241// ─── 500 helpers ────────────────────────────────────────────────────────────
242
243/// Walk the `std::error::Error::source()` chain and collect every
244/// `Display` message into a `Vec<String>`. The first entry is the top-level
245/// error itself; subsequent entries are its causes.
246///
247/// Used by the handler-error path (where `Err` variants produce 500s) to
248/// surface the full cause chain in dev-mode 500 pages. The panic path uses
249/// a synthetic single-element chain instead (panics aren't `dyn Error`).
250pub fn collect_error_chain(top: &str, mut source: Option<&dyn std::error::Error>) -> Vec<String> {
251 let mut chain = vec![top.to_owned()];
252 while let Some(cause) = source {
253 chain.push(cause.to_string());
254 source = cause.source();
255 }
256 chain
257}
258
259/// Determine whether the current settings are dev mode.
260///
261/// Returns `false` when the settings OnceLock isn't initialised (i.e. tests
262/// that exercise the 500 helpers directly without calling `App::build`).
263fn is_dev_mode() -> bool {
264 crate::settings::SETTINGS
265 .get()
266 .map(|s| matches!(s.environment, crate::settings::Environment::Dev))
267 .unwrap_or(false)
268}
269
270/// Build the template context for a 500 response.
271///
272/// In dev mode, `error_display`, `error_chain` (Vec<String>), and
273/// `request_path` are populated. In prod they are empty string / empty
274/// vec / empty string so the template's conditional block collapses
275/// to nothing.
276fn build_500_context(
277 error_display: &str,
278 error_chain: &[String],
279 request_path: &str,
280 dev: bool,
281) -> minijinja::Value {
282 if dev {
283 context! {
284 dev_mode => true,
285 error_display => error_display,
286 error_chain => error_chain,
287 request_path => request_path,
288 }
289 } else {
290 context! {
291 dev_mode => false,
292 error_display => "",
293 error_chain => Vec::<String>::new(),
294 request_path => "",
295 }
296 }
297}
298
299/// Render the 500 template with the given context.
300///
301/// Resolves the effective template name the same way `render_not_found`
302/// resolves the 404: user-supplied name → embedded default → plain text.
303/// If the chosen template itself errors during render (the
304/// recovery-path-failed case: usually a `{% extends "wrapper.html" %}`
305/// that breaks because wrapper.html shares the bug that fired the
306/// original 500), the secondary error gets `tracing::error!`'d AND
307/// — when dev mode is on — embedded in the plain-text fallback body
308/// so the developer sees the recovery failure inline instead of
309/// staring at a generic "Internal Server Error" while the real
310/// chain hides in the logs.
311fn render_500(template: Option<&str>, ctx: &minijinja::Value) -> (String, &'static str) {
312 let effective = template.or_else(|| {
313 if default_pages_enabled() {
314 Some(DEFAULT_500_TEMPLATE_NAME)
315 } else {
316 None
317 }
318 });
319
320 let Some(name) = effective else {
321 return (
322 "Internal Server Error".to_string(),
323 "text/plain; charset=utf-8",
324 );
325 };
326
327 match crate::templates::render(name, ctx) {
328 Ok(html) => (html, "text/html; charset=utf-8"),
329 Err(secondary) => {
330 // The secondary failure WAS being silently swallowed by
331 // `.ok()`. Loud-fail it instead — the operator needs to
332 // see both errors (the original handler 500 already
333 // logged in `render_500_middleware`, plus this one).
334 tracing::error!(
335 template = %name,
336 error = %secondary,
337 "render_500: secondary template render failed; the configured \
338 server-error template can't render itself. Likely a broken \
339 `{{% extends \"wrapper.html\" %}}` chain. Falling back to \
340 plain text.",
341 );
342 if is_dev_mode() {
343 // In dev, include both errors in the body so the
344 // user doesn't have to grep server logs to see why
345 // their 500 page didn't render.
346 let body = format!(
347 "Internal Server Error\n\n\
348 (dev) The configured 500 template `{name}` itself failed \
349 to render: {secondary}\n\n\
350 Check the original handler error in the server logs \
351 (line above this one) for the trigger."
352 );
353 (body, "text/plain; charset=utf-8")
354 } else {
355 (
356 "Internal Server Error".to_string(),
357 "text/plain; charset=utf-8",
358 )
359 }
360 }
361 }
362}
363
364/// Build the panic-handler closure for
365/// `tower_http::catch_panic::CatchPanicLayer::custom`.
366///
367/// Renders the configured `server_error_template` (or the built-in default
368/// when enabled) with optional dev-mode error context. Before rendering,
369/// calls the `on_server_error` hook if one was registered.
370///
371/// In dev mode the template receives:
372/// - `dev_mode: true`
373/// - `error_display`: the stringified panic payload
374/// - `error_chain`: `[error_display]` (panics have no error chain)
375/// - `request_path`: empty string (not available in a panic handler)
376///
377/// In prod, all three are empty.
378pub fn server_error_panic_handler(
379 template: Option<String>,
380 hook: Option<ServerErrorHook>,
381) -> impl Fn(Box<dyn Any + Send + 'static>) -> Response<Body> + Clone + Send + Sync + 'static {
382 move |err: Box<dyn Any + Send + 'static>| {
383 // Extract a human-readable panic message for the log line.
384 let panic_message = if let Some(s) = err.downcast_ref::<&'static str>() {
385 (*s).to_string()
386 } else if let Some(s) = err.downcast_ref::<String>() {
387 s.clone()
388 } else {
389 "<non-string panic payload>".to_string()
390 };
391 tracing::error!(
392 panic_message = %panic_message,
393 "handler panicked; serving 500 page",
394 );
395
396 // Fire the on_server_error hook before rendering.
397 if let Some(ref h) = hook {
398 h(&panic_message, "");
399 }
400
401 let dev = is_dev_mode();
402 let chain = vec![panic_message.clone()];
403 let ctx = build_500_context(&panic_message, &chain, "", dev);
404 let (body, content_type) = render_500(template.as_deref(), &ctx);
405
406 (
407 StatusCode::INTERNAL_SERVER_ERROR,
408 [(header::CONTENT_TYPE, content_type)],
409 body,
410 )
411 .into_response()
412 }
413}
414
415/// Build an axum fallback or middleware that converts a handler `Err`
416/// response into a 500 with optional dev-mode detail and hook notification.
417///
418/// Used internally when a handler returns a type that produces a 500
419/// status code (e.g. `(StatusCode::INTERNAL_SERVER_ERROR, body)`). The
420/// wrapper intercepts 500 responses, fires the hook if set, and optionally
421/// re-renders them through the 500 template.
422///
423/// Because axum handlers choose their own `IntoResponse` impl, this path
424/// is specifically for handlers that return
425/// `(StatusCode::INTERNAL_SERVER_ERROR, ...)` tuples. Panics are caught
426/// by the `CatchPanicLayer` above.
427///
428/// Note: this function is primarily used by the test suite to verify that
429/// `on_server_error` fires for handler errors. In production the hook is
430/// most naturally wired through a middleware.
431pub fn fire_server_error_hook(hook: &Option<ServerErrorHook>, error_msg: &str, path: &str) {
432 if let Some(h) = hook {
433 h(error_msg, path);
434 }
435}
436
437// ─── Response-rendering middleware (handler-Err path) ───────────────────────
438
439/// State for the response-rendering middleware. Cloned per-request; both
440/// fields are cheap to clone (`Option<String>` + `Option<Arc<...>>`).
441#[derive(Clone)]
442pub struct Render500State {
443 pub template: Option<String>,
444 pub hook: Option<ServerErrorHook>,
445}
446
447/// Turn an error response body into a human sentence for an error page.
448///
449/// gaps3 #57. `ApiError` — the error type every handler is supposed to return — renders
450/// as JSON, because its first audience is an API client. The error-page middlewares then
451/// captured that JSON *verbatim* and printed it as the page's message, so a browser hit
452/// a styled 500 that said `{"code":"database_error","error":"internal server error"}`.
453///
454/// That papercut is not cosmetic: it is a reason not to use `ApiError` in an HTML
455/// handler, which leaves the handler hand-rolling `(StatusCode, String)` — and the
456/// hand-rolled version passes `err.to_string()` straight to the browser, leaking table
457/// names and SQL fragments. The ugly page pushed people toward the leaky pattern.
458///
459/// So: if the body is a JSON object carrying a message, use that message. Anything else
460/// (plain text, an empty body, non-JSON) passes through untouched.
461fn humanize_error_body(raw: &str) -> String {
462 let trimmed = raw.trim();
463 if !trimmed.starts_with('{') {
464 return raw.to_string();
465 }
466 let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) else {
467 return raw.to_string();
468 };
469 // `error` is ApiError's message key; `detail` is the throttling / DRF-style one.
470 for key in ["error", "detail", "message"] {
471 if let Some(msg) = v.get(key).and_then(|m| m.as_str()) {
472 if !msg.is_empty() {
473 return msg.to_string();
474 }
475 }
476 }
477 // A validation failure has no single message — flatten the field errors into
478 // something a human can act on rather than showing them raw JSON.
479 if let Some(fields) = v.get("field_errors").and_then(|f| f.as_object()) {
480 let mut parts: Vec<String> = fields
481 .iter()
482 .map(|(field, msgs)| {
483 let joined = msgs
484 .as_array()
485 .map(|a| {
486 a.iter()
487 .filter_map(|m| m.as_str())
488 .collect::<Vec<_>>()
489 .join(" ")
490 })
491 .unwrap_or_default();
492 format!("{field}: {joined}")
493 })
494 .collect();
495 parts.sort();
496 if !parts.is_empty() {
497 return parts.join("; ");
498 }
499 }
500 raw.to_string()
501}
502
503/// Middleware that intercepts plain-text 500 responses and re-renders them
504/// through the configured `server_error_template` (or the embedded default
505/// when enabled). Already-HTML 500 responses pass through untouched — those
506/// were rendered by `CatchPanicLayer` (panics) or by the handler itself.
507///
508/// This closes the gap where a handler returning
509/// `Err((StatusCode::INTERNAL_SERVER_ERROR, msg))` previously produced a
510/// raw plain-text response instead of the configured 500 page. The
511/// `on_server_error` hook also fires for these paths, with the response
512/// body bytes as the error message and the request URI as the path.
513pub async fn render_500_middleware(
514 axum::extract::State(state): axum::extract::State<Render500State>,
515 req: axum::extract::Request,
516 next: axum::middleware::Next,
517) -> Response<Body> {
518 let path = req.uri().path().to_string();
519 let resp = next.run(req).await;
520
521 if resp.status() != StatusCode::INTERNAL_SERVER_ERROR {
522 return resp;
523 }
524
525 // Already-rendered HTML 500s (from CatchPanicLayer or a custom handler)
526 // pass through. Only the raw text/plain or no-content-type 500s get
527 // re-rendered.
528 let ct = resp
529 .headers()
530 .get(header::CONTENT_TYPE)
531 .and_then(|v| v.to_str().ok())
532 .unwrap_or("");
533 if ct.starts_with("text/html") {
534 return resp;
535 }
536
537 // Capture the body to extract the error message for the hook + dev
538 // context. 64KB cap: error messages don't need more, and we don't
539 // want a malicious upstream to OOM us.
540 let (_parts, body) = resp.into_parts();
541 let bytes = axum::body::to_bytes(body, 64 * 1024)
542 .await
543 .unwrap_or_default();
544 let error_msg = humanize_error_body(&String::from_utf8_lossy(&bytes));
545
546 tracing::error!(
547 error = %error_msg,
548 path = %path,
549 "handler returned 500; rendering server-error template",
550 );
551
552 fire_server_error_hook(&state.hook, &error_msg, &path);
553
554 let dev = is_dev_mode();
555 let chain = vec![error_msg.clone()];
556 let ctx = build_500_context(&error_msg, &chain, &path, dev);
557 let (body_str, content_type) = render_500(state.template.as_deref(), &ctx);
558
559 (
560 StatusCode::INTERNAL_SERVER_ERROR,
561 [(header::CONTENT_TYPE, content_type)],
562 body_str,
563 )
564 .into_response()
565}
566
567// ─── General error pages (any status code) ──────────────────────────────────
568
569/// State for the general error-page middleware: a status → template-name map.
570/// Cloned per request (an `Arc`, cheap).
571#[derive(Clone)]
572pub struct RenderErrorState {
573 pub templates: std::sync::Arc<std::collections::HashMap<StatusCode, String>>,
574}
575
576/// Middleware that styles error responses for ANY registered status code
577/// (e.g. 429, 403, 410) the way [`render_500_middleware`] does for 500. After
578/// the handler runs, if the response status has a registered template and the
579/// body isn't already HTML, the body text is captured as the `message` and the
580/// template is rendered in its place — preserving the original status code.
581///
582/// Registered via `App::builder().error_template(status, "name.html")`. 404
583/// and 500 keep their dedicated paths (`not_found_template` /
584/// `server_error_template`); this covers everything else a handler returns as
585/// `Err((status, message))`.
586pub async fn render_error_middleware(
587 axum::extract::State(state): axum::extract::State<RenderErrorState>,
588 req: axum::extract::Request,
589 next: axum::middleware::Next,
590) -> Response<Body> {
591 let path = req.uri().path().to_string();
592 // API / AJAX clients (Accept: application/json) keep the raw status +
593 // message body so they can read it programmatically; only browser
594 // navigations (Accept: text/html, the default) get the styled HTML page.
595 let wants_json = req
596 .headers()
597 .get(header::ACCEPT)
598 .and_then(|v| v.to_str().ok())
599 .map(|a| a.contains("application/json"))
600 .unwrap_or(false);
601 let resp = next.run(req).await;
602
603 let status = resp.status();
604 let Some(template) = state.templates.get(&status).cloned() else {
605 return resp;
606 };
607 if wants_json {
608 return resp;
609 }
610
611 // Already-HTML error responses (a handler that rendered its own page) pass
612 // through untouched — only bare text/plain (or no content-type) errors get
613 // the styled template.
614 let ct = resp
615 .headers()
616 .get(header::CONTENT_TYPE)
617 .and_then(|v| v.to_str().ok())
618 .unwrap_or("");
619 if ct.starts_with("text/html") {
620 return resp;
621 }
622
623 // Capture the body (the handler's message). 64KB cap, same as the 500 path.
624 let (_parts, body) = resp.into_parts();
625 let bytes = axum::body::to_bytes(body, 64 * 1024)
626 .await
627 .unwrap_or_default();
628 let message = humanize_error_body(&String::from_utf8_lossy(&bytes));
629
630 let ctx = error_context(status, &message, &path, is_dev_mode());
631 let (body_str, content_type) = render_error_page(&template, status, &ctx);
632
633 (status, [(header::CONTENT_TYPE, content_type)], body_str).into_response()
634}
635
636/// Template context for a general error page: `{ status, status_text, message,
637/// request_path, dev_mode }`.
638fn error_context(status: StatusCode, message: &str, path: &str, dev: bool) -> minijinja::Value {
639 minijinja::context! {
640 status => status.as_u16(),
641 status_text => status.canonical_reason().unwrap_or(""),
642 message => message,
643 request_path => path,
644 dev_mode => dev,
645 }
646}
647
648/// Render `template` for an error page, falling back to the status' canonical
649/// reason phrase as plain text if the template can't render. Mirrors the
650/// loud-fail posture of [`render_500`].
651fn render_error_page(
652 template: &str,
653 status: StatusCode,
654 ctx: &minijinja::Value,
655) -> (String, &'static str) {
656 match crate::templates::render(template, ctx) {
657 Ok(html) => (html, "text/html; charset=utf-8"),
658 Err(secondary) => {
659 tracing::error!(
660 template = %template,
661 status = %status.as_u16(),
662 error = %secondary,
663 "render_error_page: the configured error template failed to render; \
664 falling back to plain text",
665 );
666 let reason = status.canonical_reason().unwrap_or("Error");
667 (reason.to_string(), "text/plain; charset=utf-8")
668 }
669 }
670}
671
672// ─── Tests ──────────────────────────────────────────────────────────────────
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn error_context_carries_status_reason_message_and_path() {
680 let ctx = error_context(
681 StatusCode::TOO_MANY_REQUESTS,
682 "slow down",
683 "/p/notes",
684 false,
685 );
686 let mut env = minijinja::Environment::new();
687 env.add_template(
688 "t",
689 "{{ status }}|{{ status_text }}|{{ message }}|{{ request_path }}|{{ dev_mode }}",
690 )
691 .unwrap();
692 let out = env.get_template("t").unwrap().render(ctx).unwrap();
693 assert_eq!(out, "429|Too Many Requests|slow down|/p/notes|false");
694 }
695
696 #[test]
697 fn render_error_page_falls_back_to_plain_text_when_template_cant_render() {
698 // No ambient template engine in this unit test, so `render()` errors
699 // and we land on the canonical-reason plain-text fallback.
700 let ctx = error_context(StatusCode::TOO_MANY_REQUESTS, "msg", "/x", false);
701 let (body, ct) = render_error_page("nonexistent.html", StatusCode::TOO_MANY_REQUESTS, &ctx);
702 assert!(ct.starts_with("text/plain"), "content-type: {ct}");
703 assert_eq!(body, "Too Many Requests");
704 }
705
706 #[test]
707 fn render_not_found_returns_plain_text_when_no_template() {
708 let resp = render_not_found(None, "/missing");
709 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
710 let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
711 assert!(ct.to_str().unwrap().starts_with("text/plain"));
712 }
713
714 #[test]
715 fn render_not_found_falls_back_to_plain_text_when_template_missing() {
716 // No templates engine initialised in this test — render() errors
717 // out, so we should land on the plain-text fallback even though
718 // a template name was provided.
719 let resp = render_not_found(Some("nonexistent.html"), "/x");
720 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
721 }
722
723 #[test]
724 fn default_404_renders_route_panel_when_dev_mode_and_registry_populated() {
725 // Render the embedded default template through a fresh
726 // minijinja environment so the test doesn't depend on the
727 // (OnceLock-published) global engine state. We feed the same
728 // ctx shape `render_not_found` builds in dev mode and assert
729 // the route list lands in the output.
730 let mut env = minijinja::Environment::new();
731 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
732 env.add_template("default_404.html", DEFAULT_404_HTML)
733 .unwrap();
734
735 let ctx = minijinja::context! {
736 path => "/typo",
737 dev_mode => true,
738 routes_by_plugin => serde_json::json!([
739 {
740 "plugin": "app",
741 "routes": [
742 { "path": "/", "methods": ["GET"], "method_label": "GET" },
743 { "path": "/articles", "methods": ["GET","POST"], "method_label": "GET·POST" },
744 ],
745 },
746 {
747 "plugin": "admin",
748 "routes": [
749 { "path": "/admin/", "methods": ["GET"], "method_label": "GET" },
750 { "path": "/admin/login", "methods": ["GET","POST"], "method_label": "GET·POST" },
751 ],
752 },
753 ]),
754 };
755 let out = env
756 .get_template("default_404.html")
757 .unwrap()
758 .render(&ctx)
759 .unwrap();
760
761 // minijinja's HTML autoescape encodes `/` as `/` inside
762 // text nodes — the assertion checks the escaped form (which is
763 // what the browser will then unescape and display verbatim).
764 assert!(
765 out.contains("Dev only"),
766 "dev-mode panel header should be in the output"
767 );
768 assert!(
769 out.contains("/admin/login"),
770 "admin route should be listed: {out}"
771 );
772 assert!(
773 out.contains("/articles"),
774 "app route should be listed: {out}"
775 );
776 // Method badges land in the markup.
777 assert!(
778 out.contains("GET·POST"),
779 "composite-method badge label should render: {out}"
780 );
781 // GET-coloured badge applied to the bare-GET row.
782 assert!(
783 out.contains("emerald"),
784 "GET badge should carry the emerald tint class"
785 );
786 }
787
788 #[test]
789 fn default_404_copy_button_does_not_interpolate_path_into_js() {
790 // Regression for the reflected-XSS finding: the request path is
791 // fully attacker-controlled and must never cross into a
792 // JavaScript-string context. HTML autoescape only guards the
793 // HTML-text/attribute context; the browser HTML-decodes an
794 // `onclick` attribute before the JS parser sees it, so an escaped
795 // quote still closes a JS string literal. The copy button must
796 // therefore read the path from the inert DOM text node rather
797 // than have it interpolated into `writeText('...')`.
798 let mut env = minijinja::Environment::new();
799 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
800 env.add_template("default_404.html", DEFAULT_404_HTML)
801 .unwrap();
802
803 // A path crafted to break out of `writeText('…')` and execute JS.
804 let ctx = minijinja::context! {
805 path => "/x');alert(document.domain);('",
806 dev_mode => false,
807 routes_by_plugin => Vec::<minijinja::Value>::new(),
808 };
809 let out = env
810 .get_template("default_404.html")
811 .unwrap()
812 .render(&ctx)
813 .unwrap();
814
815 // No path data may be interpolated into a JS string literal.
816 // The literal `writeText('` prefix only exists in the vulnerable
817 // form where `{{ path }}` was inlined between the quotes; the
818 // fixed handler reads `writeText(document…textContent)`.
819 assert!(
820 !out.contains("writeText('"),
821 "copy button must not interpolate the path into a JS string literal: {out}"
822 );
823 // The raw (un-encoded) payload must not appear anywhere in the
824 // output — the safe contexts (title/text node) HTML-encode it.
825 assert!(
826 !out.contains("');alert(document.domain);('"),
827 "un-encoded JS-breakout payload leaked into the output: {out}"
828 );
829 }
830
831 #[test]
832 fn default_500_copy_button_does_not_interpolate_path_into_js() {
833 // Same JS-string-context guard as the 404 test. `request_path` is
834 // blanked in production, but dev/staging servers are frequently
835 // exposed, so the copy button must never inline the path into JS.
836 let mut env = minijinja::Environment::new();
837 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
838 env.add_template("default_500.html", DEFAULT_500_HTML)
839 .unwrap();
840
841 let ctx = minijinja::context! {
842 dev_mode => true,
843 error_display => "boom",
844 error_chain => vec!["boom".to_owned()],
845 request_path => "/x');alert(document.domain);('",
846 };
847 let out = env
848 .get_template("default_500.html")
849 .unwrap()
850 .render(&ctx)
851 .unwrap();
852
853 assert!(
854 !out.contains("writeText('"),
855 "copy button must not interpolate request_path into a JS string literal: {out}"
856 );
857 assert!(
858 !out.contains("');alert(document.domain);('"),
859 "un-encoded JS-breakout payload leaked into the output: {out}"
860 );
861 }
862
863 #[test]
864 fn default_404_omits_route_panel_when_dev_mode_is_off() {
865 // Same template, but `dev_mode = false` — the panel block must
866 // collapse to nothing. The page should still render the path
867 // and the action buttons (those are outside the gated block).
868 let mut env = minijinja::Environment::new();
869 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
870 env.add_template("default_404.html", DEFAULT_404_HTML)
871 .unwrap();
872
873 let ctx = minijinja::context! {
874 path => "/typo",
875 dev_mode => false,
876 routes_by_plugin => Vec::<minijinja::Value>::new(),
877 };
878 let out = env
879 .get_template("default_404.html")
880 .unwrap()
881 .render(&ctx)
882 .unwrap();
883
884 assert!(
885 !out.contains("Dev only"),
886 "production response must not surface the route registry"
887 );
888 }
889
890 #[test]
891 fn collect_error_chain_single_level() {
892 let chain = collect_error_chain("top error", None);
893 assert_eq!(chain, vec!["top error"]);
894 }
895
896 #[test]
897 fn build_500_context_prod_mode_has_empty_fields() {
898 let ctx = build_500_context("boom", &["boom".to_owned()], "/path", false);
899 // Serialize to JSON and inspect: prod mode has dev_mode=false and
900 // empty error_display.
901 let json = serde_json::to_value(&ctx).expect("context serialises");
902 assert_eq!(json["dev_mode"], serde_json::Value::Bool(false));
903 assert_eq!(
904 json["error_display"],
905 serde_json::Value::String("".to_string())
906 );
907 }
908
909 #[test]
910 fn build_500_context_dev_mode_has_error_info() {
911 let chain = vec!["cause one".to_owned(), "cause two".to_owned()];
912 let ctx = build_500_context("top error", &chain, "/api/items", true);
913 let json = serde_json::to_value(&ctx).expect("context serialises");
914 assert_eq!(json["dev_mode"], serde_json::Value::Bool(true));
915 assert_eq!(
916 json["error_display"],
917 serde_json::Value::String("top error".to_string())
918 );
919 // error_chain should be a two-element array
920 let arr = json["error_chain"]
921 .as_array()
922 .expect("error_chain is array");
923 assert_eq!(arr.len(), 2);
924 }
925}