Skip to main content

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