1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
//! Template error page for development.
//!
//! In production a failed Tera render returns a plain 500 and the
//! operator reads the details from the log. That is no help locally,
//! where the developer wants to see the failure in the browser.
//!
//! [`enabled`] says whether this process should show the page.
//! [`error_page_html`] builds it from a Tera error.
//!
//! **The page shows internal details, so never turn it on in
//! production.** It prints everything [`tera::Error`] carries: the
//! message, the debug form and the full source chain. The CSS is
//! inline, so the page needs no static asset that might fail for the
//! same reason the template did.
//!
//! ## Wiring
//!
//! Anywhere a caller invokes `Tera::render` and turns the result into
//! an HTTP response, replace
//!
//! ```ignore
//! match tera.render(name, ctx) {
//! Ok(html) => axum::response::Html(html).into_response(),
//! Err(e) => /* plain-text 500 */,
//! }
//! ```
//!
//! with:
//!
//! ```ignore
//! match tera.render(name, ctx) {
//! Ok(html) => axum::response::Html(html).into_response(),
//! Err(e) if rustango::template_debug::enabled() => (
//! axum::http::StatusCode::INTERNAL_SERVER_ERROR,
//! axum::response::Html(rustango::template_debug::error_page_html(&e, name)),
//! ).into_response(),
//! Err(_) => /* plain-text 500 */,
//! }
//! ```
//!
//! `template_views::render` already does this. It also checks
//! `error::disclose_server_errors()`, so a locked-down process keeps
//! the plain 500.
//!
//! [`enabled`]: crate::template_debug::enabled
//! [`error_page_html`]: crate::template_debug::error_page_html
/// `true` when this process should show the debug page instead of a
/// plain 500. Two inputs, in order:
///
/// 1. `RUSTANGO_TEMPLATE_DEBUG`. A truthy value (`1`, `true`, `yes`,
/// `on`) forces it on; a falsy one (`0`, `false`, `no`, `off`)
/// forces it off. Case does not matter.
/// 2. Otherwise `RUSTANGO_ENV`: `prod` or `production` means off,
/// anything else (or nothing) means on.
///
/// Reading the env each call is cheap and avoids a load-order problem
/// with config. For a compile-time switch, guard the call site with
/// `#[cfg(debug_assertions)]`.
///
/// The answer comes from `error.rs`, because every 5xx path needs the
/// same one and this module is gated on `_tera`.
#[must_use]
pub fn enabled() -> bool {
crate::error::debug_details_enabled()
}
/// Build an HTML page describing a template render failure: the
/// template name, the error, and the full source chain. The CSS is
/// inline, since the template system that just failed cannot be
/// trusted to render its own error page.
///
/// The page exposes internal details, so only serve it when
/// [`enabled`] is true.
#[must_use]
pub fn error_page_html(err: &tera::Error, template_name: &str) -> String {
use std::error::Error as _;
use std::fmt::Write as _;
let mut buf = String::with_capacity(2_048);
let _ = write!(
buf,
r#"<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<title>Template error: {name}</title>
<style>
body {{
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 0; background: #fafafa; color: #1a1a1a;
}}
.banner {{
background: #b00020; color: #fff; padding: 1.2rem 2rem;
}}
.banner h1 {{ margin: 0; font-size: 1.5rem; }}
.banner small {{ display: block; opacity: 0.8; margin-top: 0.2rem; }}
main {{ padding: 1.5rem 2rem; max-width: 64rem; }}
section {{ margin-bottom: 1.5rem; }}
h2 {{ font-size: 1rem; text-transform: uppercase;
letter-spacing: 0.05em; color: #777; margin: 0 0 0.4rem 0; }}
pre, code {{ font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 13px; }}
pre {{ background: #fff; border: 1px solid #ddd;
border-radius: 4px; padding: 0.8rem 1rem; overflow-x: auto;
white-space: pre-wrap; word-break: break-word; }}
.footer {{ color: #888; font-size: 12px; padding: 1rem 2rem; }}
</style>
</head><body>
<div class="banner">
<h1>Template error</h1>
<small>Rendering <code>{name}</code></small>
</div>
<main>
"#,
name = escape_html(template_name),
);
let _ = write!(
buf,
"<section><h2>Error</h2><pre>{}</pre></section>\n",
escape_html(&err.to_string()),
);
// `tera::Error::kind` is private, but `Debug` on the error shows the
// kind and any wrapped payload.
let _ = write!(
buf,
"<section><h2>Debug</h2><pre>{}</pre></section>\n",
escape_html(&format!("{err:?}")),
);
// Walk the source chain for parse line numbers and any I/O error
// Tera wrapped.
let mut chain: Vec<String> = Vec::new();
let mut current: Option<&dyn std::error::Error> = err.source();
while let Some(e) = current {
chain.push(e.to_string());
current = e.source();
}
if !chain.is_empty() {
let _ = write!(buf, "<section><h2>Caused by</h2><pre>");
for (i, line) in chain.iter().enumerate() {
let _ = write!(buf, "{i}. {}\n", escape_html(line));
}
let _ = write!(buf, "</pre></section>\n");
}
buf.push_str(
r#"<section><h2>Why am I seeing this?</h2>
<p>This page is rendered when <code>rustango::template_debug::enabled()</code>
is true. Set <code>RUSTANGO_ENV=prod</code> or
<code>RUSTANGO_TEMPLATE_DEBUG=0</code> to switch back to the
plain-text 500 response.</p>
</section>
"#,
);
buf.push_str("</main>\n<div class=\"footer\">rustango template debug</div>\n</body></html>");
buf
}
// Escapes raw error text without going back through Tera, which just failed.
use crate::text::html_escape as escape_html;
#[cfg(test)]
mod tests {
use super::*;
// `error::server_error_body` reads the same env vars, so share its
// test lock instead of adding a second one.
use crate::error::test_env::{lock as env_lock, with as with_env};
#[test]
fn enabled_defaults_to_true_when_no_env_set() {
let _g = env_lock();
with_env("RUSTANGO_TEMPLATE_DEBUG", None, || {
with_env("RUSTANGO_ENV", None, || {
assert!(enabled(), "missing both env vars → dev tier → debug on");
});
});
}
#[test]
fn enabled_is_false_in_prod_tier() {
let _g = env_lock();
with_env("RUSTANGO_TEMPLATE_DEBUG", None, || {
with_env("RUSTANGO_ENV", Some("prod"), || {
assert!(!enabled(), "prod tier → debug off");
});
with_env("RUSTANGO_ENV", Some("production"), || {
assert!(!enabled(), "`production` long form → debug off");
});
});
}
#[test]
fn enabled_is_true_in_staging_and_dev_tiers() {
let _g = env_lock();
with_env("RUSTANGO_TEMPLATE_DEBUG", None, || {
with_env("RUSTANGO_ENV", Some("dev"), || {
assert!(enabled());
});
with_env("RUSTANGO_ENV", Some("staging"), || {
assert!(enabled());
});
});
}
#[test]
fn explicit_template_debug_override_wins_over_env_tier() {
let _g = env_lock();
// prod tier plus explicit "1" forces it on.
with_env("RUSTANGO_ENV", Some("prod"), || {
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("1"), || {
assert!(enabled(), "explicit `1` forces debug on in prod");
});
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("true"), || {
assert!(enabled());
});
});
// dev tier plus explicit "0" forces it off.
with_env("RUSTANGO_ENV", Some("dev"), || {
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("0"), || {
assert!(!enabled(), "explicit `0` forces debug off in dev");
});
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("off"), || {
assert!(!enabled());
});
});
}
#[test]
fn error_page_html_contains_template_name_and_error() {
// Build a real `tera::Error` by triggering a parse failure.
let mut tera = tera::Tera::default();
let err = tera
.add_raw_template("broken.html", "{% if %}{% endif %}")
.expect_err("intentionally bad template");
let page = error_page_html(&err, "broken.html");
assert!(page.contains("Template error"), "needs banner header");
assert!(page.contains("broken.html"), "must echo template name");
// The error's Display includes the parser diagnostic.
assert!(
page.contains("Failed to parse") || page.contains("parse"),
"should surface the parse failure text, got: {page}"
);
// Inline CSS, so the page needs no static asset.
assert!(page.contains("<style>"));
// No raw angle brackets leak from the error message.
assert!(
!page.contains("<%"),
"any `<` in error text must be escaped"
);
}
#[test]
fn error_page_escapes_html_in_template_name() {
// A template name with angle brackets must not break the page.
let mut tera = tera::Tera::default();
let err = tera
.add_raw_template("x", "{% if %}{% endif %}")
.expect_err("intentionally bad template");
let page = error_page_html(&err, "<script>alert(1)</script>");
assert!(!page.contains("<script>alert(1)</script>"));
assert!(page.contains("<script>alert(1)</script>"));
}
#[test]
fn escape_html_handles_all_five_entities() {
assert_eq!(escape_html("&"), "&");
assert_eq!(escape_html("<"), "<");
assert_eq!(escape_html(">"), ">");
assert_eq!(escape_html("\""), """);
assert_eq!(escape_html("'"), "'");
assert_eq!(escape_html("abc"), "abc");
}
}