ADR-017: Escaping Is the Template’s Contract, Not the Caller’s Discipline

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)


1. Context

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.


2. Decisions

D1 — A template grammar escapes every hole, unconditionally.

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.

D2 — The one way to say “this is already markup” is a type, not a flag.

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 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.

D4 — The primitive lives in 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.

D5 — What this ADR does not decide.

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.


3. Consequences

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.


4. What was rejected

This site is open source. Improve this page.