Skip to main content

forma_server/
template.rs

1use crate::csp::{self, CspNonce};
2use crate::head::HeadLink;
3use crate::render::RenderCtx;
4use crate::response::RenderCounts;
5use crate::types::{AssetManifest, PageConfig, PageOutput, RenderMode};
6use forma_ir as ir;
7use std::time::Instant;
8
9/// Resolved asset URLs for a route — shared between Phase 1 and Phase 2 paths.
10struct ResolvedAssets {
11    fonts: Vec<String>,
12    css_urls: Vec<String>,
13    js_urls: Vec<String>,
14}
15
16/// Escape text for an HTML attribute value or text content.
17pub(crate) fn escape_html(s: &str) -> String {
18    s.replace('&', "&amp;")
19        .replace('<', "&lt;")
20        .replace('>', "&gt;")
21        .replace('"', "&quot;")
22}
23
24/// Neutralize the only two sequences that can end an inline `<script>` element
25/// or start a comment-driven double-escape inside one.
26///
27/// `</script` → `<\/script` and `<!--` → `<\!--`. Both are identity escapes in
28/// a JS string, template literal or regex, so the *value* is preserved — which
29/// is why `<!--` becomes `<\!--` and not `<--` (the latter changes the string's
30/// contents and parses as `< --` in code position).
31///
32/// The match is case-insensitive because HTML tag names are: `</ScRiPt >`
33/// closes the element just as well as `</script>`. Matching bare `</script`
34/// (rather than `</script` plus a tag-name terminator, which is what the
35/// tokenizer requires) is slightly over-broad, and that is fine here because
36/// the transform is value-preserving.
37///
38/// This makes the value unable to escape its element. It does NOT make an
39/// untrusted value safe — see the doc comment on `PageConfig::config_script`.
40fn neutralize_script_terminators(s: &str) -> String {
41    let lower = s.to_ascii_lowercase();
42    let mut out = String::with_capacity(s.len() + 8);
43    let mut i = 0;
44    while i < s.len() {
45        if lower[i..].starts_with("</script") {
46            out.push_str("<\\/script");
47            i += "</script".len();
48        } else if lower[i..].starts_with("<!--") {
49            out.push_str("<\\!--");
50            i += "<!--".len();
51        } else {
52            // Walk by char so multi-byte UTF-8 is never split. `i < s.len()`
53            // and `i` is always on a char boundary, so there is a next char.
54            let Some(ch) = s[i..].chars().next() else {
55                break;
56            };
57            out.push(ch);
58            i += ch.len_utf8();
59        }
60    }
61    out
62}
63
64/// Build the common `<head>` content shared by both render paths.
65///
66/// `preloads` and `links` are the typed homes a [`RenderCtx`] carries;
67/// a render without one passes neither, and its head is byte-identical to
68/// 0.2.1's.
69fn build_head(
70    title: &str,
71    nonce: &str,
72    assets: &ResolvedAssets,
73    personality_css: Option<&str>,
74    preloads: &[String],
75    links: &[HeadLink],
76) -> String {
77    let mut head = String::with_capacity(2048);
78    head.push_str("<meta charset=\"utf-8\">\n");
79    head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
80    head.push_str(&format!("<title>{}</title>\n", escape_html(title)));
81
82    // Font preloads
83    for font in &assets.fonts {
84        head.push_str(&format!(
85            "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
86            escape_html(font)
87        ));
88    }
89
90    // CSS stylesheets
91    for css in &assets.css_urls {
92        head.push_str(&format!(
93            "<link rel=\"stylesheet\" href=\"{}\">\n",
94            escape_html(css)
95        ));
96    }
97
98    // JS modulepreloads. Nonced because under `script-src 'strict-dynamic'` an
99    // un-nonced script-destination preload hint is dropped. Losing it is a
100    // degradation, not a breakage — the real module tag is nonced and its
101    // static imports inherit that nonce, so the chunk still loads; you lose a
102    // warm-cache round trip and gain a console violation.
103    for js in &assets.js_urls {
104        head.push_str(&format!(
105            "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\">\n",
106            escape_html(nonce),
107            escape_html(js)
108        ));
109    }
110
111    // The route's static dependencies from the build's manifest (DR-19), in
112    // the same form and for the same reason: the typed home for Ledger's
113    // preloads in `body_prefix`. The table leaves out the route's own
114    // modules and repeats (`ModulePreloads`).
115    for file in preloads {
116        head.push_str(&format!(
117            "<link rel=\"modulepreload\" nonce=\"{}\" href=\"/_assets/{}\">\n",
118            escape_html(nonce),
119            escape_html(file)
120        ));
121    }
122
123    // Head links (icons, a web app manifest), every value escaped (DR-19).
124    for link in links {
125        link.write(&mut head);
126    }
127
128    // Personality CSS (inline, small).
129    //
130    // Neutralize `</`, not `</style>`. A literal two-case string replace was
131    // never sufficient: HTML tag names are case-insensitive and the tokenizer
132    // accepts whitespace and `/` before `>`, so `</StYlE>`, `</style >`,
133    // `</style\t>` and `</style/>` all escaped the element. The ONLY way out
134    // of a RAWTEXT element is `</` followed by a tag name — character
135    // references are not decoded and comments are not recognised there — so if
136    // no `</` survives, no end tag can be produced.
137    //
138    // `\/` is a valid CSS escape for `/`, valid inside strings, `url()` tokens
139    // and idents, and it consumes no following whitespace, so real CSS
140    // round-trips byte-identically (`content:"</div>"` still computes to
141    // `</div>`). Escaping `<` instead would be secure but silently breaks
142    // modern CSS: `\00003c` is an ident-start escape, not the `<` delim token
143    // that the Media Queries Level 4 range grammar needs, so
144    // `@media (width < 600px)` would parse and then never match.
145    if let Some(css) = personality_css {
146        let safe_css = css.replace("</", "<\\/");
147        head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
148    }
149
150    head
151}
152
153/// Resolve asset URLs from the manifest for a given route pattern.
154fn resolve_assets(manifest: &AssetManifest, route_pattern: &str) -> ResolvedAssets {
155    let route = manifest.route(route_pattern);
156    ResolvedAssets {
157        fonts: route
158            .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
159            .unwrap_or_default(),
160        css_urls: route
161            .map(|r| r.css.iter().map(|f| format!("/_assets/{f}")).collect())
162            .unwrap_or_default(),
163        js_urls: route
164            .map(|r| r.js.iter().map(|f| format!("/_assets/{f}")).collect())
165            .unwrap_or_default(),
166    }
167}
168
169/// Build shared body fragments (class attr, prefix, config script, page JS tag).
170struct BodyParts {
171    body_class_attr: String,
172    body_prefix: String,
173    config_script_tag: String,
174    page_js_tag: String,
175    wasm_script: String,
176    sw_script: String,
177}
178
179fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[String]) -> BodyParts {
180    let body_class_attr = config
181        .body_class
182        .map(|c| format!(" class=\"{}\"", escape_html(c)))
183        .unwrap_or_default();
184    // Raw, trusted HTML by contract — see PageConfig::body_prefix. Written
185    // as it is; a plain string, not a RawHtml, logs RAW-101 once in debug
186    // builds (DR-19).
187    let body_prefix = config.body_prefix.unwrap_or("");
188    crate::raw::check_body_prefix(body_prefix, config.route_pattern);
189    let body_prefix = body_prefix.to_string();
190
191    // `config_json` is the safe path: serde_json output with every `<`
192    // escaped, which simultaneously kills `</script`, `<!--` and `<script`
193    // and is value-preserving in JSON (`<` is a valid string escape, so
194    // JSON.parse yields byte-identical values).
195    let config_json_tag = config
196        .config_json
197        .map(|value| {
198            let json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
199            format!(
200                "<script nonce=\"{nonce}\">window.__FORMA_CONFIG__={};</script>\n",
201                json.replace('<', "\\u003c")
202            )
203        })
204        .unwrap_or_default();
205
206    // `config_script` is the raw escape hatch. Neutralizing the terminators
207    // stops it breaking out of its own element; it does not make untrusted
208    // content safe inside it. Deprecated (DR-18), and still written exactly
209    // as at 0.2.1 until 3.0.0 removes it.
210    #[allow(deprecated)]
211    let config_script = config.config_script;
212    let config_script_tag = config_script
213        .map(|s| {
214            format!(
215                "<script nonce=\"{nonce}\">{}</script>\n",
216                neutralize_script_terminators(s)
217            )
218        })
219        .unwrap_or_default();
220    let config_script_tag = format!("{config_json_tag}{config_script_tag}");
221
222    let page_js_tag = js_urls
223        .last()
224        .map(|url| {
225            format!(
226                "<script type=\"module\" nonce=\"{nonce}\" src=\"{}\"></script>",
227                escape_html(url)
228            )
229        })
230        .unwrap_or_default();
231
232    // The WASM walker's bootstrap, for a manifest that names one: deprecated
233    // with `AssetManifest::wasm`, and removed with it in 3.0.0. Until then
234    // the page is what 0.2.1 wrote.
235    #[allow(deprecated)]
236    let wasm_script = match (
237        &config.manifest.wasm,
238        config.manifest.route(config.route_pattern),
239    ) {
240        (Some(wasm), Some(route)) => match route.ir.as_ref() {
241            Some(ir_name) => format!(
242                "<script nonce=\"{nonce}\">window.__FORMA_WASM__={{loader:\"/_assets/{}\",binary:\"/_assets/{}\",ir:\"/_assets/{}\"}};</script>\n",
243                escape_html(&wasm.loader), escape_html(&wasm.binary), escape_html(ir_name)
244            ),
245            None => String::new(),
246        },
247        _ => String::new(),
248    };
249
250    // Only for an application that enabled its worker (DR-25, Q15): the
251    // default registers nothing, and `/sw.js` retires any worker installed
252    // by an earlier release.
253    let sw_script = crate::sw::registration_script(nonce);
254
255    BodyParts {
256        body_class_attr,
257        body_prefix,
258        config_script_tag,
259        page_js_tag,
260        wasm_script,
261        sw_script,
262    }
263}
264
265/// Render a page, generating a fresh CSP nonce for it.
266///
267/// The returned HTML and CSP header are ONE UNIT: the HTML embeds the nonce
268/// that only this policy blesses. Send the page with
269/// [`PageOutput::into_page_response`], which writes `Cache-Control: no-store`,
270/// the policy and the document headers whenever the response is sent.
271/// Caching the HTML while regenerating the CSP kills every script on the
272/// page; caching both ships one static, publicly-known nonce to all users and
273/// defeats nonce-based CSP.
274///
275/// Deprecated: any load or walk failure here serves the empty Phase 1
276/// shell with a 200 and a log line the handler never sees. Use
277/// [`crate::try_render`], which returns the failure as a coded
278/// [`crate::RenderError`], and [`crate::render_browser_shell`] for a route
279/// declared browser-rendered. This function keeps its fallback until 3.0.0
280/// removes it, and in debug builds logs its deprecation once per process.
281#[deprecated(
282    since = "0.2.2",
283    note = "serves an empty shell with a 200 on any failure; use `try_render`, which returns \
284            the failure as a `RenderError`, and `render_browser_shell` for a route declared \
285            `ssr: false`. Removed in 3.0.0"
286)]
287pub fn render_page(config: &PageConfig) -> PageOutput {
288    log_deprecation_once();
289    render_with_nonce(config, &csp::generate_csp_nonce())
290}
291
292/// The runtime half of `render_page`'s deprecation: one `tracing` warning
293/// per process, in debug builds only, so development sees it and production
294/// logs do not fill with it.
295///
296/// Verified by: crates/forma-server/tests/render_page_deprecation.rs > "render_page_logs_its_deprecation_once_per_process_in_debug_builds"
297fn log_deprecation_once() {
298    #[cfg(debug_assertions)]
299    {
300        use std::sync::atomic::{AtomicBool, Ordering};
301        static LOGGED: AtomicBool = AtomicBool::new(false);
302        if !LOGGED.swap(true, Ordering::Relaxed) {
303            tracing::warn!(
304                "forma_server::render_page is deprecated: it serves an empty shell with a 200 \
305                 when a render fails; use try_render, which returns the failure. \
306                 Logged once per process, in debug builds."
307            );
308        }
309    }
310}
311
312/// [`render_page`] with a caller-supplied per-request nonce.
313///
314/// Takes a validated [`CspNonce`] rather than a `&str`: the value is
315/// interpolated unescaped into five HTML attributes and into the CSP header,
316/// so an arbitrary string would be an attribute- and directive-injection sink.
317///
318/// Deprecated: from 3.0.0 the render draws every response's nonce and
319/// no caller supplies one, so a cached shell cannot be re-stamped with a
320/// nonce of the caller's choosing.
321#[deprecated(
322    since = "0.2.2",
323    note = "the render draws the nonce: use `try_render`, which returns a `PageResponse`. \
324            Removed in 3.0.0"
325)]
326pub fn render_page_with_nonce(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
327    render_with_nonce(config, nonce)
328}
329
330/// The one render behind [`render_page`] and [`render_page_with_nonce`].
331fn render_with_nonce(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
332    match config.render_mode {
333        RenderMode::Phase1ClientMount => render_page_phase1(config, nonce),
334        RenderMode::Phase2SsrReconcile => render_page_phase2(config, nonce),
335    }
336}
337
338/// The document around the page's root: empty for a client-mounted page,
339/// or holding the walk's HTML. `ctx` carries the typed homes; the deprecated
340/// renders pass an empty one and write 0.2.1's document.
341pub(crate) fn write_document(
342    config: &PageConfig,
343    nonce: &CspNonce,
344    ssr_body: Option<&str>,
345    ctx: &RenderCtx,
346) -> String {
347    // Defence in depth: CspNonce already rejects anything attribute- or
348    // directive-significant, so this never changes a real nonce.
349    let nonce_attr = escape_html(nonce.as_str());
350    let assets = resolve_assets(config.manifest, config.route_pattern);
351    let head = build_head(
352        config.title,
353        &nonce_attr,
354        &assets,
355        config.personality_css,
356        ctx.module_preloads(config.route_pattern),
357        ctx.head_links(),
358    );
359    let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
360
361    // Pushed rather than `format!`ed, so the walk's HTML (up to the 8 MiB
362    // budget) is copied once, into the document, and not first into a root.
363    let ssr_len = ssr_body.map_or(0, str::len);
364    let mut html = String::with_capacity(head.len() + ssr_len + 1024);
365    html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
366    html.push_str(&head);
367    html.push_str("</head>\n<body");
368    html.push_str(&parts.body_class_attr);
369    html.push_str(">\n");
370    html.push_str(&parts.body_prefix);
371    match ssr_body {
372        Some(ssr) => {
373            html.push_str("<div id=\"app\" data-forma-ssr>");
374            html.push_str(ssr);
375            html.push_str("</div>");
376        }
377        None => html.push_str("<div id=\"app\"></div>"),
378    }
379    html.push('\n');
380    html.push_str(&parts.config_script_tag);
381    html.push_str(&parts.page_js_tag);
382    html.push('\n');
383    html.push_str(&parts.wasm_script);
384    html.push_str(&parts.sw_script);
385    html.push_str("</body>\n</html>");
386    html
387}
388
389/// A [`PageOutput`] with the strict policy for its nonce.
390#[allow(deprecated)] // the fields are deprecated for callers, not for us
391fn page_output(html: String, nonce: &CspNonce, counts: RenderCounts) -> PageOutput {
392    PageOutput {
393        html,
394        csp: csp::build_csp_header(nonce),
395        nonce: nonce.clone(),
396        counts,
397    }
398}
399
400/// Phase 1: empty `<div id="app"></div>` — client JS mounts from scratch.
401fn render_page_phase1(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
402    page_output(
403        write_document(config, nonce, None, &RenderCtx::default()),
404        nonce,
405        RenderCounts::default(),
406    )
407}
408
409/// Phase 2: server-render the IR module into `<div id="app" data-forma-ssr>`.
410/// Falls back to Phase 1 on any error (missing IR module, walk failure, etc.).
411fn render_page_phase2(config: &PageConfig, nonce: &CspNonce) -> PageOutput {
412    // Both ir_module and slots must be present for SSR
413    let (ir_module, slots) = match (config.ir_module, config.slots) {
414        (Some(m), Some(s)) => (m, s),
415        _ => {
416            tracing::warn!(
417                route = config.route_pattern,
418                "Phase 2 SSR: missing IR module or slots, falling back to Phase 1"
419            );
420            return render_page_phase1(config, nonce);
421        }
422    };
423
424    // Attempt IR walk. This is also where the walker's resource budgets
425    // surface — an over-limit or malformed module degrades to Phase 1 rather
426    // than aborting the process.
427    let started = Instant::now();
428    let walked = ir::walker::walk_to_html(ir_module, slots);
429    let walk_time = started.elapsed();
430    let ssr_body = match walked {
431        Ok(html) => html,
432        Err(err) => {
433            tracing::warn!(
434                route = config.route_pattern,
435                error = %err,
436                "Phase 2 SSR: IR walk failed, falling back to Phase 1"
437            );
438            return render_page_phase1(config, nonce);
439        }
440    };
441
442    let counts = RenderCounts {
443        bytes: ssr_body.len(),
444        walk_time,
445        ..RenderCounts::default()
446    };
447    page_output(
448        write_document(config, nonce, Some(&ssr_body), &RenderCtx::default()),
449        nonce,
450        counts,
451    )
452}
453
454#[cfg(test)]
455// The 0.2.1 API these tests pin is deprecated, not changed: they keep
456// calling it to prove `render_page`'s output is what it was.
457#[allow(deprecated)]
458mod tests {
459    use super::*;
460    use crate::types::{RouteAssets, WasmAssets};
461    use forma_ir::parser::IrModule;
462    use forma_ir::slot::{SlotData, SlotValue};
463    use std::collections::HashMap;
464
465    /// Build a minimal AssetManifest with no routes for testing.
466    fn empty_manifest() -> AssetManifest {
467        AssetManifest {
468            version: 1,
469            build_hash: "test".to_string(),
470            assets: HashMap::new(),
471            routes: HashMap::new(),
472            wasm: None,
473        }
474    }
475
476    /// Build a valid IR module that renders "<p>hello</p>".
477    fn hello_ir_module() -> IrModule {
478        use forma_ir::parser::test_helpers::{
479            build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
480        };
481
482        let mut opcodes = Vec::new();
483        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <p>
484        opcodes.extend_from_slice(&encode_text(1)); // hello
485        opcodes.extend_from_slice(&encode_close_tag(0)); // </p>
486
487        let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
488        IrModule::parse(&data).unwrap()
489    }
490
491    #[test]
492    fn phase1_renders_empty_app_div() {
493        let manifest = empty_manifest();
494        let page = render_page(&PageConfig {
495            title: "Test",
496            route_pattern: "/test",
497            manifest: &manifest,
498            config_script: None,
499            config_json: None,
500            body_class: None,
501            personality_css: None,
502            body_prefix: None,
503            render_mode: RenderMode::Phase1ClientMount,
504            ir_module: None,
505            slots: None,
506        });
507
508        assert!(page.html.contains("<div id=\"app\"></div>"));
509        // Check that the #app div does NOT have the SSR attribute
510        assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
511    }
512
513    #[test]
514    fn phase2_renders_ssr_content_with_data_attr() {
515        let manifest = empty_manifest();
516        let ir = hello_ir_module();
517        let slots = SlotData::new(0);
518
519        let page = render_page(&PageConfig {
520            title: "SSR Test",
521            route_pattern: "/test",
522            manifest: &manifest,
523            config_script: None,
524            config_json: None,
525            body_class: None,
526            personality_css: None,
527            body_prefix: None,
528            render_mode: RenderMode::Phase2SsrReconcile,
529            ir_module: Some(&ir),
530            slots: Some(&slots),
531        });
532
533        assert!(page.html.contains("data-forma-ssr"));
534        assert!(page.html.contains("<p>hello</p>"));
535        assert!(page.html.contains("<div id=\"app\" data-forma-ssr>"));
536    }
537
538    #[test]
539    fn phase2_falls_back_to_phase1_when_ir_module_missing() {
540        let manifest = empty_manifest();
541
542        let page = render_page(&PageConfig {
543            title: "Fallback Test",
544            route_pattern: "/test",
545            manifest: &manifest,
546            config_script: None,
547            config_json: None,
548            body_class: None,
549            personality_css: None,
550            body_prefix: None,
551            render_mode: RenderMode::Phase2SsrReconcile,
552            ir_module: None,
553            slots: None,
554        });
555
556        // Should fall back to Phase 1: empty #app, no data-forma-ssr
557        assert!(page.html.contains("<div id=\"app\"></div>"));
558        assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
559    }
560
561    #[test]
562    fn phase2_falls_back_to_phase1_when_slots_missing() {
563        let manifest = empty_manifest();
564        let ir = hello_ir_module();
565
566        let page = render_page(&PageConfig {
567            title: "Fallback Test",
568            route_pattern: "/test",
569            manifest: &manifest,
570            config_script: None,
571            config_json: None,
572            body_class: None,
573            personality_css: None,
574            body_prefix: None,
575            render_mode: RenderMode::Phase2SsrReconcile,
576            ir_module: Some(&ir),
577            slots: None,
578        });
579
580        assert!(page.html.contains("<div id=\"app\"></div>"));
581        assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
582    }
583
584    #[test]
585    fn phase2_falls_back_on_ir_walk_error() {
586        let manifest = empty_manifest();
587        // A TEXT opcode referencing string index 99 with only one string in
588        // the table. This parses (IrModule::parse validates structure and
589        // cross-table ids, not every string index) and fails during the walk —
590        // an invalid *opcode* byte no longer works for this test because the
591        // structural pass now rejects it at load time.
592        let mut opcodes = vec![0x04]; // TEXT
593        opcodes.extend_from_slice(&99u32.to_le_bytes());
594        let data = forma_ir::parser::test_helpers::build_minimal_ir(&["x"], &[], &opcodes, &[]);
595        let ir = IrModule::parse(&data).unwrap();
596        let slots = SlotData::new(0);
597
598        let page = render_page(&PageConfig {
599            title: "Walk Error Test",
600            route_pattern: "/test",
601            manifest: &manifest,
602            config_script: None,
603            config_json: None,
604            body_class: None,
605            personality_css: None,
606            body_prefix: None,
607            render_mode: RenderMode::Phase2SsrReconcile,
608            ir_module: Some(&ir),
609            slots: Some(&slots),
610        });
611
612        // Should fall back to Phase 1
613        assert!(page.html.contains("<div id=\"app\"></div>"));
614        assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
615    }
616
617    #[test]
618    fn phase2_ssr_preserves_head_content() {
619        let manifest = empty_manifest();
620        let ir = hello_ir_module();
621        let slots = SlotData::new(0);
622
623        let page = render_page(&PageConfig {
624            title: "Head Test",
625            route_pattern: "/test",
626            manifest: &manifest,
627            config_script: Some("window.__TEST__=true;"),
628            config_json: None,
629            body_class: Some("dark"),
630            personality_css: Some(":root{--c:red}"),
631            body_prefix: Some("<nav>nav</nav>"),
632            render_mode: RenderMode::Phase2SsrReconcile,
633            ir_module: Some(&ir),
634            slots: Some(&slots),
635        });
636
637        assert!(page.html.contains("<title>Head Test</title>"));
638        assert!(page.html.contains("window.__TEST__=true;"));
639        assert!(page.html.contains("class=\"dark\""));
640        assert!(page.html.contains(":root{--c:red}"));
641        assert!(page.html.contains("<nav>nav</nav>"));
642        assert!(page.html.contains("<p>hello</p>"));
643        assert!(page.html.contains("data-forma-ssr"));
644    }
645
646    #[test]
647    fn wasm_script_injected_when_manifest_has_wasm_and_route_has_ir() {
648        let mut routes = HashMap::new();
649        routes.insert(
650            "/test".to_string(),
651            RouteAssets {
652                js: vec!["test.abc123.js".to_string()],
653                css: vec![],
654                fonts: vec![],
655                total_size_br: 0,
656                budget_warn_threshold: 204800,
657                ir: Some("test.def456.ir".to_string()),
658            },
659        );
660
661        let manifest = AssetManifest {
662            version: 1,
663            build_hash: "test".to_string(),
664            assets: HashMap::new(),
665            routes,
666            wasm: Some(WasmAssets {
667                loader: "forma_ir.abc.js".to_string(),
668                binary: "forma_ir_bg.def.wasm".to_string(),
669            }),
670        };
671
672        let page = render_page(&PageConfig {
673            title: "WASM Test",
674            route_pattern: "/test",
675            manifest: &manifest,
676            config_script: None,
677            config_json: None,
678            body_class: None,
679            personality_css: None,
680            body_prefix: None,
681            render_mode: RenderMode::Phase1ClientMount,
682            ir_module: None,
683            slots: None,
684        });
685
686        assert!(page.html.contains("__FORMA_WASM__"));
687        assert!(page.html.contains("/_assets/forma_ir.abc.js"));
688        assert!(page.html.contains("/_assets/forma_ir_bg.def.wasm"));
689        assert!(page.html.contains("/_assets/test.def456.ir"));
690    }
691
692    #[test]
693    fn wasm_script_not_injected_when_no_wasm_in_manifest() {
694        let manifest = empty_manifest();
695        let page = render_page(&PageConfig {
696            title: "No WASM",
697            route_pattern: "/test",
698            manifest: &manifest,
699            config_script: None,
700            config_json: None,
701            body_class: None,
702            personality_css: None,
703            body_prefix: None,
704            render_mode: RenderMode::Phase1ClientMount,
705            ir_module: None,
706            slots: None,
707        });
708
709        assert!(!page.html.contains("__FORMA_WASM__"));
710    }
711
712    #[test]
713    fn wasm_script_not_injected_when_route_has_no_ir() {
714        let mut routes = HashMap::new();
715        routes.insert(
716            "/test".to_string(),
717            RouteAssets {
718                js: vec!["test.abc123.js".to_string()],
719                css: vec![],
720                fonts: vec![],
721                total_size_br: 0,
722                budget_warn_threshold: 204800,
723                ir: None, // no IR
724            },
725        );
726
727        let manifest = AssetManifest {
728            version: 1,
729            build_hash: "test".to_string(),
730            assets: HashMap::new(),
731            routes,
732            wasm: Some(WasmAssets {
733                loader: "forma_ir.abc.js".to_string(),
734                binary: "forma_ir_bg.def.wasm".to_string(),
735            }),
736        };
737
738        let page = render_page(&PageConfig {
739            title: "No IR",
740            route_pattern: "/test",
741            manifest: &manifest,
742            config_script: None,
743            config_json: None,
744            body_class: None,
745            personality_css: None,
746            body_prefix: None,
747            render_mode: RenderMode::Phase1ClientMount,
748            ir_module: None,
749            slots: None,
750        });
751
752        assert!(!page.html.contains("__FORMA_WASM__"));
753    }
754
755    #[test]
756    fn phase2_with_slot_data() {
757        use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
758
759        // Build IR with DYN_TEXT referencing slot 0
760        let mut opcodes = Vec::new();
761        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <span>
762                                                             // DYN_TEXT opcode: 0x05 + slot_id(u16) + marker_id(u16)
763        opcodes.push(0x05);
764        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot_id = 0
765        opcodes.extend_from_slice(&0u16.to_le_bytes()); // marker_id = 0
766        opcodes.extend_from_slice(&encode_close_tag(0)); // </span>
767
768        let data = build_minimal_ir(
769            &["span", "name"],          // strings
770            &[(0, 1, 0x01, 0x00, &[])], // slot: id=0, name_str_idx=1, type=Text, source=Server
771            &opcodes,
772            &[],
773        );
774        let ir = IrModule::parse(&data).unwrap();
775
776        let mut slots = SlotData::new(1);
777        slots.set(0, SlotValue::Text("World".to_string()));
778
779        let manifest = empty_manifest();
780        let page = render_page(&PageConfig {
781            title: "Slot Test",
782            route_pattern: "/test",
783            manifest: &manifest,
784            config_script: None,
785            config_json: None,
786            body_class: None,
787            personality_css: None,
788            body_prefix: None,
789            render_mode: RenderMode::Phase2SsrReconcile,
790            ir_module: Some(&ir),
791            slots: Some(&slots),
792        });
793
794        assert!(page.html.contains("data-forma-ssr"));
795        // DYN_TEXT wraps content in marker comments for client reconciliation
796        assert!(page
797            .html
798            .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
799    }
800
801    // -- Security: escape_html tests -----------------------------------------
802
803    #[test]
804    fn escape_html_basic_entities() {
805        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
806        assert_eq!(escape_html("\"quotes\""), "&quot;quotes&quot;");
807        assert_eq!(escape_html("a&b"), "a&amp;b");
808        assert_eq!(escape_html("clean"), "clean");
809    }
810
811    #[test]
812    fn asset_filename_xss_is_escaped() {
813        // If a manifest somehow contained a malicious filename, it must be escaped
814        // in the HTML output rather than injected raw.
815        let mut routes = HashMap::new();
816        routes.insert(
817            "/test".to_string(),
818            RouteAssets {
819                js: vec!["\"><script>alert(1)</script>".to_string()],
820                css: vec!["\"><script>alert(2)</script>".to_string()],
821                fonts: vec!["\"><script>alert(3)</script>".to_string()],
822                total_size_br: 0,
823                budget_warn_threshold: 204800,
824                ir: None,
825            },
826        );
827        let manifest = AssetManifest {
828            version: 1,
829            build_hash: "test".to_string(),
830            assets: HashMap::new(),
831            routes,
832            wasm: None,
833        };
834
835        let page = render_page(&PageConfig {
836            title: "XSS Test",
837            route_pattern: "/test",
838            manifest: &manifest,
839            config_script: None,
840            config_json: None,
841            body_class: None,
842            personality_css: None,
843            body_prefix: None,
844            render_mode: RenderMode::Phase1ClientMount,
845            ir_module: None,
846            slots: None,
847        });
848
849        // The raw "<script>" must not appear unescaped in the output
850        assert!(
851            !page.html.contains("<script>alert("),
852            "asset filenames must be HTML-escaped, got: {}",
853            page.html
854        );
855        assert!(
856            page.html.contains("&lt;script&gt;"),
857            "escaped <script> should be present"
858        );
859    }
860
861    #[test]
862    fn body_class_with_quotes_is_escaped() {
863        let manifest = empty_manifest();
864        let page = render_page(&PageConfig {
865            title: "Body Class Test",
866            route_pattern: "/test",
867            manifest: &manifest,
868            config_script: None,
869            config_json: None,
870            body_class: Some("dark\" onload=\"alert(1)"),
871            personality_css: None,
872            body_prefix: None,
873            render_mode: RenderMode::Phase1ClientMount,
874            ir_module: None,
875            slots: None,
876        });
877
878        // The double quote in body_class must be escaped so the attacker
879        // cannot break out of the class="..." attribute.
880        // Raw: class="dark" onload="alert(1)"  (attribute injection)
881        // Safe: class="dark&quot; onload=&quot;alert(1)"  (single quoted attr value)
882        assert!(
883            page.html.contains("&quot;"),
884            "body_class double quote must be escaped"
885        );
886        assert!(
887            !page.html.contains(r#"class="dark" onload="#),
888            "body_class must not allow attribute breakout via unescaped double quote"
889        );
890    }
891
892    #[test]
893    fn personality_css_style_breakout_prevented() {
894        // Table-driven, because the old two-case literal replace passed a test
895        // that only checked the adjacent-literal shape. HTML tag names are
896        // case-insensitive and the tokenizer accepts whitespace and `/` before
897        // `>`, so every one of these used to escape the <style> element.
898        for css in [
899            "body{color:red}</style><script>alert(1)</script>",
900            "body{color:red}</StYlE><script>alert(1)</script>",
901            "body{color:red}</style ><script>alert(1)</script>",
902            "body{color:red}</style\t><script>alert(1)</script>",
903            "body{color:red}</style/><script>alert(1)</script>",
904            "body{color:red}</style\n><script>alert(1)</script>",
905            "body{color:red}</style\x0c><script>alert(1)</script>",
906            "body{color:red}<</style><script>alert(1)</script>",
907            "body{color:red}</sty</style><script>alert(1)</script>",
908        ] {
909            let manifest = empty_manifest();
910            let page = render_page(&PageConfig {
911                title: "CSS Test",
912                route_pattern: "/test",
913                manifest: &manifest,
914                config_script: None,
915                config_json: None,
916                body_class: None,
917                personality_css: Some(css),
918                body_prefix: None,
919                render_mode: RenderMode::Phase1ClientMount,
920                ir_module: None,
921                slots: None,
922            });
923
924            // Assert the real invariant, not one literal shape: the rendered
925            // <style> region must contain no `</` other than the closing tag
926            // the template itself appends. `</` is the ONLY way out of a
927            // RAWTEXT element, so if none survives, no end tag can be formed.
928            let start = page.html.find("<style").expect("style element");
929            let body_start = page.html[start..].find('>').expect("style open") + start + 1;
930            let end = page.html[body_start..]
931                .find("</style>")
932                .expect("style close")
933                + body_start;
934            let body = &page.html[body_start..end];
935            assert!(
936                !body.contains("</"),
937                "style body must contain no `</` for input {css:?}, got: {body}"
938            );
939        }
940    }
941
942    #[test]
943    fn personality_css_round_trips_legitimate_values() {
944        // `\/` is a valid CSS escape for `/`, so real CSS is unchanged in
945        // meaning. Range media queries in particular must survive: escaping
946        // `<` instead (the obvious alternative) turns `@media (width < 600px)`
947        // into an ident token that parses but never matches.
948        let manifest = empty_manifest();
949        let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
950                   .a::after{content:\"</div>\"}\
951                   .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
952        let page = render_page(&PageConfig {
953            title: "CSS Test",
954            route_pattern: "/test",
955            manifest: &manifest,
956            config_script: None,
957            config_json: None,
958            body_class: None,
959            personality_css: Some(css),
960            body_prefix: None,
961            render_mode: RenderMode::Phase1ClientMount,
962            ir_module: None,
963            slots: None,
964        });
965
966        // Untouched: no `<` before a `/`.
967        assert!(page.html.contains("@media (width < 600px)"));
968        assert!(page.html.contains("font:12px/1.4 sans-serif"));
969        assert!(page.html.contains("aspect-ratio:16/9"));
970        // Escaped but value-preserving: `content:"<\/div>"` computes to
971        // `</div>`.
972        assert!(page.html.contains("content:\"<\\/div>\""));
973    }
974
975    // -- config_script / config_json (S3) -----------------------------------
976
977    /// Render a page with the given config_script and return the HTML.
978    fn page_with_config_script(script: &str) -> String {
979        let manifest = empty_manifest();
980        render_page(&PageConfig {
981            title: "Config Test",
982            route_pattern: "/test",
983            manifest: &manifest,
984            config_script: Some(script),
985            config_json: None,
986            body_class: None,
987            personality_css: None,
988            body_prefix: None,
989            render_mode: RenderMode::Phase1ClientMount,
990            ir_module: None,
991            slots: None,
992        })
993        .html
994    }
995
996    #[test]
997    fn config_script_terminators_are_neutralized() {
998        // </script closes the tag and turns the remainder into live markup...
999        let html = page_with_config_script("window.x=\"</script><img src=x onerror=alert(1)>\";");
1000        assert!(!html.contains("</script><img"));
1001        assert!(html.contains("<\\/script>"));
1002
1003        // ...and the match must be case-insensitive, because the tokenizer is.
1004        let html = page_with_config_script("window.x=\"</ScRiPt ><img src=x>\";");
1005        assert!(!html.to_ascii_lowercase().contains("</script ><img"));
1006
1007        // <!--<script drives the script-data-double-escaped state, where the
1008        // element's own closing tag stops ending it.
1009        let html = page_with_config_script("window.x=\"<!--<script>PAYLOAD\";");
1010        assert!(!html.contains("<!--<script"));
1011        // `\!` is an identity escape in a JS string, so the value survives.
1012        assert!(html.contains("<\\!--"));
1013    }
1014
1015    #[test]
1016    fn config_json_emits_no_raw_angle_brackets() {
1017        let manifest = empty_manifest();
1018        let value = serde_json::json!({
1019            "user": "</script><img src=x onerror=alert(1)>",
1020            "note": "<!--<script>",
1021        });
1022        let page = render_page(&PageConfig {
1023            title: "Config Test",
1024            route_pattern: "/test",
1025            manifest: &manifest,
1026            config_script: None,
1027            config_json: Some(&value),
1028            body_class: None,
1029            personality_css: None,
1030            body_prefix: None,
1031            render_mode: RenderMode::Phase1ClientMount,
1032            ir_module: None,
1033            slots: None,
1034        });
1035
1036        let start = page
1037            .html
1038            .find("window.__FORMA_CONFIG__=")
1039            .expect("config json script")
1040            + "window.__FORMA_CONFIG__=".len();
1041        let end = page.html[start..].find("</script>").expect("close") + start;
1042        let json = &page.html[start..end];
1043        assert!(
1044            !json.contains('<'),
1045            "config_json output must contain no raw '<', got: {json}"
1046        );
1047        // ...and it is value-preserving: < is a valid JSON escape.
1048        let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
1049        assert_eq!(parsed, value);
1050    }
1051
1052    // -- Nonce plumbing (S11) -----------------------------------------------
1053
1054    #[test]
1055    fn page_output_exposes_the_nonce_it_embedded() {
1056        let manifest = empty_manifest();
1057        let page = render_page(&PageConfig {
1058            title: "Nonce Test",
1059            route_pattern: "/test",
1060            manifest: &manifest,
1061            config_script: Some("window.x=1;"),
1062            config_json: None,
1063            body_class: None,
1064            personality_css: Some("body{color:red}"),
1065            body_prefix: None,
1066            render_mode: RenderMode::Phase1ClientMount,
1067            ir_module: None,
1068            slots: None,
1069        });
1070
1071        // No string-parsing the CSP to recover it.
1072        assert!(page.csp.contains(&format!("'nonce-{}'", page.nonce)));
1073        assert!(page
1074            .html
1075            .contains(&format!("<script nonce=\"{}\">", page.nonce)));
1076        assert!(page
1077            .html
1078            .contains(&format!("<style nonce=\"{}\">", page.nonce)));
1079    }
1080
1081    #[test]
1082    fn render_page_with_nonce_uses_the_supplied_nonce() {
1083        // The cache-aware path: a caller re-stamping a shell per request must
1084        // get the same nonce in the HTML and the header.
1085        let manifest = empty_manifest();
1086        let nonce = crate::csp::CspNonce::generate();
1087        let page = render_page_with_nonce(
1088            &PageConfig {
1089                title: "Nonce Test",
1090                route_pattern: "/test",
1091                manifest: &manifest,
1092                config_script: None,
1093                config_json: None,
1094                body_class: None,
1095                personality_css: None,
1096                body_prefix: None,
1097                render_mode: RenderMode::Phase1ClientMount,
1098                ir_module: None,
1099                slots: None,
1100            },
1101            &nonce,
1102        );
1103        assert_eq!(page.nonce, nonce);
1104        assert!(page.csp.contains(&format!("'nonce-{nonce}'")));
1105    }
1106
1107    #[test]
1108    fn page_output_headers_are_no_store() {
1109        // The HTML embeds a per-request nonce, so it must never be cached.
1110        let manifest = empty_manifest();
1111        let page = render_page(&PageConfig {
1112            title: "Header Test",
1113            route_pattern: "/test",
1114            manifest: &manifest,
1115            config_script: None,
1116            config_json: None,
1117            body_class: None,
1118            personality_css: None,
1119            body_prefix: None,
1120            render_mode: RenderMode::Phase1ClientMount,
1121            ir_module: None,
1122            slots: None,
1123        });
1124
1125        let headers = page.headers();
1126        let get = |name: &str| {
1127            headers
1128                .iter()
1129                .find(|(n, _)| n.as_str() == name)
1130                .map(|(_, v)| v.to_str().unwrap().to_string())
1131        };
1132        assert_eq!(get("cache-control").as_deref(), Some("no-store"));
1133        assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
1134        assert!(get("content-security-policy").is_some());
1135        assert!(get("referrer-policy").is_some());
1136    }
1137
1138    // -- CSP / walker interlock (S4) ----------------------------------------
1139
1140    #[test]
1141    fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
1142        // A DYN_ATTR bound to `style` renders a style ATTRIBUTE, so the header
1143        // this crate ships must permit attribute styles — otherwise the
1144        // framework's default CSP breaks the framework's own output.
1145        let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
1146        opcodes.push(0x06); // DYN_ATTR
1147        opcodes.extend_from_slice(&1u32.to_le_bytes()); // "style"
1148        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot 0
1149        let data = forma_ir::parser::test_helpers::build_minimal_ir(
1150            &["div", "style", "css"],
1151            &[(0, 2, 0x01, 0x00, &[])],
1152            &opcodes,
1153            &[],
1154        );
1155        let ir = IrModule::parse(&data).unwrap();
1156        let mut slots = SlotData::new(1);
1157        slots.set(0, SlotValue::Text("color:red".into()));
1158
1159        let manifest = empty_manifest();
1160        let page = render_page(&PageConfig {
1161            title: "Style Attr",
1162            route_pattern: "/test",
1163            manifest: &manifest,
1164            config_script: None,
1165            config_json: None,
1166            body_class: None,
1167            personality_css: None,
1168            body_prefix: None,
1169            render_mode: RenderMode::Phase2SsrReconcile,
1170            ir_module: Some(&ir),
1171            slots: Some(&slots),
1172        });
1173
1174        assert!(
1175            page.html.contains("style=\"color:red\""),
1176            "walker must emit the style attribute: {}",
1177            page.html
1178        );
1179        assert!(
1180            page.csp.contains("style-src-attr 'unsafe-inline'"),
1181            "CSP must permit the attribute style the walker just emitted: {}",
1182            page.csp
1183        );
1184    }
1185}