Status: Accepted
Related: ADR-296 D4 (the binding mode <for> uses),
ADR-010 D8 (provenance may supply evidence, never permission),
ADR-296 D17 (the lowering is name for name)
examples/fortunes.nika renders an HTML table from database rows, and the TechEmpower benchmark
it comes from requires the output to be correctly escaped. Writing it exposed the question the
specification had not answered: who escapes?
Three answers are in circulation, and two are wrong in ways everyone who shipped them has documented.
The caller does. Every template language that started here has since added the other answer. The failure mode is a hole somebody forgot in a file nobody re-reads — not a discipline problem better reviewers fix, but a default that is wrong once per template and silent when it is.
The template does, unless the value is trusted. The tempting one, and
ADR-010 D8 already rules it out: provenance is evidence about
where bytes came from, so one wrong trusted: true upstream becomes an XSS hole
downstream, at a distance, in a file that does not mention escaping.
The template does, always. The compiler sees every hole in every template. Nothing else in the system does.
Only the third is a contract rather than a hope, and the argument is the one the grammar protocol already rests on: a property the compiler can check at every instance is worth more than a rule the author must remember at every instance.
Not a default, not a lint, not a mode. A value that reaches a hole is escaped for the position the hole is in, and there is no argument, flag or annotation at the hole that turns it off.
Provenance does not enter. ADR-010’s Ledger decides hashing, where a wrong answer costs
performance; it does not decide escaping, where a wrong answer costs the site. The two are not
symmetric and are not treated symmetrically.
There is always such a value — a rendered fragment, a sanitised excerpt, an SVG the program built
itself — and a language that has no way to say it grows a way to say it badly. Nikaia’s way is
html::Raw:
let banner = html::Raw::new(render_banner(user)) // the audit point, and greppable
A hole whose value is Raw is written through unchanged; a hole whose value is anything else is
escaped. Why a type rather than {value | raw} at the hole:
Raw::new is one line to grep for and one line to review. A flag is a needle in every template.Raw has said so in its signature.Raw::new is not unsafe and does not need to be. It is a promise about markup, not about
memory, and the failure mode is a page rather than the process.
“Escaped” is not one operation. Text and a quoted attribute value need & < > " '; a <script>
body needs JavaScript string escaping and is not safe with HTML escaping at all; a URL-valued
attribute needs percent-encoding and a scheme check; a CSS block needs something else again. A
template grammar that cannot tell which position a hole is in cannot promise anything about it.
So the grammar knows the positions it can serve, and a hole anywhere else is a compile error naming the position:
error: a hole inside `<script>` cannot be escaped by this template
--> fortunes.nika:41
| <script>var user = {name};</script>
| ^^^^^^ HTML escaping does not make text safe here
= help: build the value as JSON and place it in a data attribute, or use a
grammar that escapes for JavaScript
This is the decision with teeth, and it is the reason D1 can be unconditional: a promise that holds only in some positions would have to be qualified everywhere, and a qualified promise is the second answer in §1 wearing a different hat.
std, not in the code generator.std::html::escape(text) is the function, and the template’s lowering calls it. One
implementation, tested on its own, and a hand-written renderer gets exactly what a template gets —
which matters because the first version of any program renders by hand.
It returns the input unchanged and unallocated when nothing needs escaping, which is the common case for a database column, so the contract costs one scan rather than one copy per hole.
Two things a reviewer should check in crates/nikaia-std/src/html.rs: that & is escaped first, since escaping it last would escape
the ampersands the other four just produced, and that escaping is not idempotent, which is
precisely why D2 gives an already-escaped value a type instead of leaving it to be recognised.
The template DSL’s own syntax — <for row in :rows>, the capture form, how a fragment is
composed — is the DSL protocol’s business (ADR-296 D4, D5). This ADR constrains any
such grammar rather than defining one: whatever the syntax, D1 to D3 hold.
For the compiler. D3 is the work: a template grammar must carry, per hole, which position it is in, and reject the positions it cannot serve. That is the same kind of static check the frame protocol already does for boundaries (ADR-296 D9) — checked, never repaired — and it fails the same way, at the pattern, with the reason named.
For the reader of a template. Every hole is escaped and no hole says so. That is the point: the thing you cannot forget is the thing that is not written down.
SafeString that any function may construct. Raw::new is deliberately the only
constructor, so that the audit is a grep rather than a type search.