Skip to main content

forma_server/
template.rs

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