Skip to main content

forma_server/
template.rs

1use crate::csp::CspNonce;
2use crate::head::HeadLink;
3use crate::render::{RenderCtx, RenderError};
4use crate::types::{AssetManifest, PageConfig};
5
6/// Resolved asset URLs for a route — shared between Phase 1 and Phase 2 paths.
7struct ResolvedAssets {
8    fonts: Vec<String>,
9    css_urls: Vec<Resource>,
10    js_urls: Vec<Resource>,
11}
12
13/// A resource whose manifest digest was checked before any document is emitted.
14struct Resource {
15    url: String,
16    integrity: String,
17}
18
19/// Escape text for an HTML attribute value or text content.
20pub(crate) fn escape_html(s: &str) -> String {
21    s.replace('&', "&amp;")
22        .replace('<', "&lt;")
23        .replace('>', "&gt;")
24        .replace('"', "&quot;")
25}
26
27/// Rule 1 applies to ordinary DOM values before HTML escaping. Transport
28/// data, nonces, digests, and build identity keep their existing exact encoding.
29pub(crate) fn escape_dom_html(s: &str) -> String {
30    escape_html(
31        &s.replace("\r\n", "\n")
32            .replace('\r', "\n")
33            .replace('\0', "\u{fffd}"),
34    )
35}
36
37/// Build the common `<head>` content shared by both render paths.
38///
39/// `preloads` and `links` are the typed homes a [`RenderCtx`] carries;
40/// a render without one passes neither. External resource tags always include SRI.
41fn build_head(
42    title: &str,
43    nonce: &str,
44    assets: &ResolvedAssets,
45    personality_css: Option<&str>,
46    preloads: &[Resource],
47    links: &[HeadLink],
48) -> String {
49    let mut head = String::with_capacity(2048);
50    head.push_str("<meta charset=\"utf-8\">\n");
51    head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
52    head.push_str(&format!("<title>{}</title>\n", escape_dom_html(title)));
53
54    // Font preloads
55    for font in &assets.fonts {
56        head.push_str(&format!(
57            "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
58            escape_dom_html(font)
59        ));
60    }
61
62    // CSS stylesheets
63    for css in &assets.css_urls {
64        head.push_str(&format!(
65            "<link rel=\"stylesheet\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
66            escape_dom_html(&css.url),
67            escape_html(&css.integrity)
68        ));
69    }
70
71    // JS modulepreloads. Nonced because under `script-src 'strict-dynamic'` an
72    // un-nonced script-destination preload hint is dropped. Losing it is a
73    // degradation, not a breakage — the real module tag is nonced and its
74    // static imports inherit that nonce, so the chunk still loads; you lose a
75    // warm-cache round trip and gain a console violation.
76    for js in &assets.js_urls {
77        head.push_str(&format!(
78            "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
79            escape_html(nonce),
80            escape_dom_html(&js.url),
81            escape_html(&js.integrity)
82        ));
83    }
84
85    // The route's static dependencies from the build's manifest (DR-19), in
86    // the same form and for the same reason: the typed home for Ledger's
87    // preloads in `body_prefix`. The table leaves out the route's own
88    // modules and repeats (`ModulePreloads`).
89    for file in preloads {
90        head.push_str(&format!(
91            "<link rel=\"modulepreload\" nonce=\"{}\" href=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\">\n",
92            escape_html(nonce),
93            escape_dom_html(&file.url),
94            escape_html(&file.integrity)
95        ));
96    }
97
98    // Head links (icons, a web app manifest), every value escaped (DR-19).
99    for link in links {
100        link.write(&mut head);
101    }
102
103    // Personality CSS (inline, small).
104    //
105    // Neutralize `</`, not `</style>`. A literal two-case string replace was
106    // never sufficient: HTML tag names are case-insensitive and the tokenizer
107    // accepts whitespace and `/` before `>`, so `</StYlE>`, `</style >`,
108    // `</style\t>` and `</style/>` all escaped the element. The ONLY way out
109    // of a RAWTEXT element is `</` followed by a tag name — character
110    // references are not decoded and comments are not recognised there — so if
111    // no `</` survives, no end tag can be produced.
112    //
113    // `\/` is a valid CSS escape for `/`, valid inside strings, `url()` tokens
114    // and idents, and it consumes no following whitespace, so real CSS
115    // round-trips byte-identically (`content:"</div>"` still computes to
116    // `</div>`). Escaping `<` instead would be secure but silently breaks
117    // modern CSS: `\00003c` is an ident-start escape, not the `<` delim token
118    // that the Media Queries Level 4 range grammar needs, so
119    // `@media (width < 600px)` would parse and then never match.
120    if let Some(css) = personality_css {
121        let safe_css = css.replace("</", "<\\/");
122        head.push_str(&format!("<style nonce=\"{nonce}\">{safe_css}</style>\n"));
123    }
124
125    head
126}
127
128/// Resolve each resource through this response's manifest snapshot.
129fn resource(manifest: &AssetManifest, file: &str, route: &str) -> Result<Resource, RenderError> {
130    let metadata = manifest
131        .assets
132        .get(file)
133        .filter(|meta| crate::types::valid_integrity(&meta.integrity))
134        .ok_or_else(|| RenderError::Manifest {
135            route: route.into(),
136            message: format!(
137                "resource {file:?} requires canonical SHA-384 integrity in this manifest"
138            ),
139        })?;
140    Ok(Resource {
141        url: format!("/_assets/{file}"),
142        integrity: metadata.integrity.clone(),
143    })
144}
145
146/// Resolve asset URLs from the manifest for a given route pattern.
147fn resolve_assets(
148    manifest: &AssetManifest,
149    route_pattern: &str,
150) -> Result<ResolvedAssets, RenderError> {
151    let route = manifest.route(route_pattern);
152    let resolve = |files: &[String]| {
153        files
154            .iter()
155            .map(|file| resource(manifest, file, route_pattern))
156            .collect::<Result<Vec<_>, _>>()
157    };
158    Ok(ResolvedAssets {
159        fonts: route
160            .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
161            .unwrap_or_default(),
162        css_urls: resolve(route.map_or(&[], |r| &r.css))?,
163        js_urls: resolve(route.map_or(&[], |r| &r.js))?,
164    })
165}
166
167/// Build shared body fragments (class attr, prefix, escaped JSON data, page JS tag).
168struct BodyParts {
169    body_class_attr: String,
170    body_prefix: String,
171    config_json_tag: String,
172    page_js_tag: String,
173    sw_script: String,
174}
175
176fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[Resource]) -> BodyParts {
177    let body_class_attr = config
178        .body_class
179        .map(|c| format!(" class=\"{}\"", escape_dom_html(c)))
180        .unwrap_or_default();
181    let body_prefix = config
182        .body_prefix
183        .map_or("", crate::RawHtml::as_str)
184        .to_string();
185
186    // `config_json` is the safe path: serde_json output with every `<`
187    // escaped, which simultaneously kills `</script`, `<!--` and `<script`
188    // and is value-preserving in JSON (`<` is a valid string escape, so
189    // JSON.parse yields byte-identical values).
190    let config_json_tag = config
191        .config_json
192        .map(|value| {
193            let json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
194            format!(
195                "<script nonce=\"{nonce}\">window.__FORMA_CONFIG__={};</script>\n",
196                json.replace('<', "\\u003c")
197            )
198        })
199        .unwrap_or_default();
200
201    // Route `js` contains declared entry modules in order. Dependency chunks
202    // live in the separate modulepreload table and must not execute eagerly.
203    let page_js_tag = js_urls
204        .iter()
205        .map(|url| {
206            format!(
207                "<script type=\"module\" nonce=\"{nonce}\" src=\"{}\" integrity=\"{}\" crossorigin=\"anonymous\"></script>",
208                escape_dom_html(&url.url),
209                escape_html(&url.integrity)
210            )
211        })
212        .collect::<Vec<_>>()
213        .join("\n");
214
215    // Registration belongs only to pages that publish a JavaScript entry.
216    // Static pages never bootstrap a worker, even in an enabled application.
217    // The worker endpoint and off-mode retirement remain independently available.
218    let sw_script = if js_urls.is_empty() {
219        String::new()
220    } else {
221        crate::sw::registration_script(nonce)
222    };
223
224    BodyParts {
225        body_class_attr,
226        body_prefix,
227        config_json_tag,
228        page_js_tag,
229        sw_script,
230    }
231}
232
233/// The document around the page's root: empty for a client-mounted page,
234/// or holding the walk's HTML. `ctx` carries the typed head metadata.
235pub(crate) fn write_document(
236    config: &PageConfig,
237    nonce: &CspNonce,
238    ssr_body: Option<&str>,
239    island_state: Option<&forma_ir::IslandState>,
240    ctx: &RenderCtx,
241    max_output_bytes: usize,
242    counts: crate::RenderCounts,
243) -> Result<String, RenderError> {
244    // Defence in depth: CspNonce already rejects anything attribute- or
245    // directive-significant, so this never changes a real nonce.
246    let nonce_attr = escape_html(nonce.as_str());
247    let assets = resolve_assets(config.manifest, config.route_pattern)?;
248    let mut preloads = Vec::new();
249    let mut seen = std::collections::HashSet::new();
250    for file in ctx.module_preloads(config.route_pattern) {
251        let url = format!("/_assets/{file}");
252        if assets.js_urls.iter().any(|asset| asset.url == url) || !seen.insert(file) {
253            continue;
254        }
255        preloads.push(resource(config.manifest, file, config.route_pattern)?);
256    }
257    let head = build_head(
258        config.title,
259        &nonce_attr,
260        &assets,
261        config.personality_css,
262        &preloads,
263        ctx.head_links(),
264    );
265    let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
266    // The root names the build the manifest names (FORMA-186, DR-29): core compares it
267    // with the identity compiled into the bundle before it activates anything. The
268    // manifest is data, so it is escaped as any attribute value is, and no render input
269    // can set or override it.
270    let build_attr = format!(
271        " data-forma-build=\"{}\"",
272        escape_html(&config.manifest.build)
273    );
274
275    // Every byte of the completed document, including state tags/envelope,
276    // counts against the same response limit. Charge before allocating/copying
277    // the final document; body walk counts remain a separate diagnostic.
278    let fragments = [
279        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n",
280        head.as_str(),
281        if island_state.is_some() {
282            "<script type=\"application/json\" data-island-state>"
283        } else {
284            ""
285        },
286        island_state.map_or("", forma_ir::IslandState::as_json),
287        if island_state.is_some() {
288            "</script>\n"
289        } else {
290            ""
291        },
292        "</head>\n<body",
293        parts.body_class_attr.as_str(),
294        ">\n",
295        parts.body_prefix.as_str(),
296        "<div id=\"app\"",
297        build_attr.as_str(),
298        if ssr_body.is_some() {
299            " data-forma-ssr>"
300        } else {
301            "></div>"
302        },
303        ssr_body.unwrap_or(""),
304        if ssr_body.is_some() { "</div>" } else { "" },
305        "\n",
306        parts.config_json_tag.as_str(),
307        parts.page_js_tag.as_str(),
308        "\n",
309        parts.sw_script.as_str(),
310        "</body>\n</html>",
311    ];
312    let total = fragments
313        .iter()
314        .try_fold(0usize, |total, part| total.checked_add(part.len()));
315    let Some(total) = total.filter(|total| *total <= max_output_bytes) else {
316        return Err(RenderError::Budget {
317            route: config.route_pattern.into(),
318            component_path: Vec::new(),
319            counts,
320            cause: forma_ir::IrError::OutputLimitExceeded {
321                limit: max_output_bytes,
322            },
323        });
324    };
325    let mut html = String::with_capacity(total);
326    for fragment in fragments {
327        html.push_str(fragment);
328    }
329
330    Ok(html)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::render_contract::{RenderFixture as PageConfig, RenderPath as RenderMode};
337    use crate::types::RouteAssets;
338    use forma_ir::parser::IrModule;
339    use forma_ir::slot::{SlotData, SlotValue};
340    use std::collections::HashMap;
341
342    fn render_parts(config: &PageConfig) -> crate::PageParts {
343        let ctx = RenderCtx::new();
344        match config.render_mode {
345            RenderMode::Phase1ClientMount => crate::render_contract::render_shell(config, &ctx),
346            RenderMode::Phase2SsrReconcile => crate::render_contract::render_ssr(config, &ctx),
347        }
348        .expect("valid fixture renders")
349        .into_parts_unchecked()
350    }
351    fn policy(page: &crate::PageParts) -> &str {
352        page.headers
353            .iter()
354            .find(|(name, _)| name == "content-security-policy")
355            .unwrap()
356            .1
357            .to_str()
358            .unwrap()
359    }
360
361    /// Build a minimal AssetManifest with no routes for testing.
362    fn empty_manifest() -> AssetManifest {
363        AssetManifest {
364            version: 2,
365            writer: "3.0.0".into(),
366            app: "ledger".into(),
367            aliases: HashMap::new(),
368            build: "test".to_string(),
369            assets: HashMap::new(),
370            routes: HashMap::new(),
371        }
372    }
373
374    #[test]
375    fn a_legacy_manifest_is_refused_before_removed_wasm_fields_can_be_used() {
376        let error = serde_json::from_value::<AssetManifest>(serde_json::json!({
377            "version":1,"build_hash":"legacy","assets":{},"routes":{},
378            "wasm":{"loader":"legacy-loader.js","binary":"legacy.wasm"}
379        }))
380        .unwrap_err();
381        assert!(error.to_string().contains("MANIFEST-101"));
382    }
383
384    /// Build a valid IR module that renders "<p>hello</p>".
385    fn hello_ir_module() -> IrModule {
386        use forma_ir::parser::test_helpers::{
387            build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
388        };
389
390        let mut opcodes = Vec::new();
391        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <p>
392        opcodes.extend_from_slice(&encode_text(1)); // hello
393        opcodes.extend_from_slice(&encode_close_tag(0)); // </p>
394
395        let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
396        IrModule::parse(&data).unwrap()
397    }
398
399    #[test]
400    fn phase1_renders_empty_app_div() {
401        let manifest = empty_manifest();
402        let page = render_parts(&PageConfig {
403            title: "Test",
404            route_pattern: "/test",
405            manifest: &manifest,
406            config_json: None,
407            body_class: None,
408            personality_css: None,
409            body_prefix: None,
410            render_mode: RenderMode::Phase1ClientMount,
411            ir_module: None,
412            slots: None,
413        });
414
415        // The root carries the build's identity (FORMA-186); the manifest's build here is "test".
416        assert!(page
417            .html
418            .contains("<div id=\"app\" data-forma-build=\"test\"></div>"));
419        // Check that the #app div does NOT have the SSR attribute
420        assert!(!page.html.contains("data-forma-ssr"));
421    }
422
423    #[test]
424    fn phase2_renders_ssr_content_with_data_attr() {
425        let manifest = empty_manifest();
426        let ir = hello_ir_module();
427        let slots = SlotData::new(0);
428
429        let page = render_parts(&PageConfig {
430            title: "SSR Test",
431            route_pattern: "/test",
432            manifest: &manifest,
433            config_json: None,
434            body_class: None,
435            personality_css: None,
436            body_prefix: None,
437            render_mode: RenderMode::Phase2SsrReconcile,
438            ir_module: Some(&ir),
439            slots: Some(&slots),
440        });
441
442        assert!(page.html.contains("data-forma-ssr"));
443        assert!(page.html.contains("<p>hello</p>"));
444        assert!(page.html.contains(
445            "<div id=\"app\" data-forma-build=\"test\" data-forma-ssr><p>hello</p></div>"
446        ));
447    }
448
449    /// The deployment identity (FORMA-186, DR-29) is the manifest's `build`, written
450    /// on the root of a server-rendered page and of a browser shell, once, escaped
451    /// as any attribute value is: a manifest is data, never markup.
452    ///
453    /// Verified by: crates/forma-server/src/template.rs > "the_root_carries_the_manifests_build_escaped_on_both_forms"
454    #[test]
455    fn the_root_carries_the_manifests_build_escaped_on_both_forms() {
456        let mut manifest = empty_manifest();
457        manifest.build = "5f0c9a1e7d2b4c86".into();
458        let ir = hello_ir_module();
459        let slots = SlotData::new(0);
460        for (mode, ir_module, slots) in [
461            (RenderMode::Phase1ClientMount, None, None),
462            (RenderMode::Phase2SsrReconcile, Some(&ir), Some(&slots)),
463        ] {
464            let page = render_parts(&PageConfig {
465                title: "Identity",
466                route_pattern: "/test",
467                manifest: &manifest,
468                config_json: None,
469                body_class: None,
470                personality_css: None,
471                body_prefix: None,
472                render_mode: mode,
473                ir_module,
474                slots,
475            });
476            assert_eq!(
477                page.html.matches("data-forma-build=").count(),
478                1,
479                "exactly one stamp: {}",
480                page.html
481            );
482            assert!(page
483                .html
484                .contains("<div id=\"app\" data-forma-build=\"5f0c9a1e7d2b4c86\""));
485        }
486        manifest.build = "a\"b<c>&d".into();
487        let page = render_parts(&PageConfig {
488            title: "Identity",
489            route_pattern: "/test",
490            manifest: &manifest,
491            config_json: None,
492            body_class: None,
493            personality_css: None,
494            body_prefix: None,
495            render_mode: RenderMode::Phase1ClientMount,
496            ir_module: None,
497            slots: None,
498        });
499        assert!(page
500            .html
501            .contains("data-forma-build=\"a&quot;b&lt;c&gt;&amp;d\""));
502        assert!(!page.html.contains("a\"b<c>"));
503    }
504
505    #[test]
506    fn phase2_ssr_preserves_head_content() {
507        let manifest = empty_manifest();
508        let ir = hello_ir_module();
509        let slots = SlotData::new(0);
510
511        let page = render_parts(&PageConfig {
512            title: "Head Test",
513            route_pattern: "/test",
514            manifest: &manifest,
515            config_json: Some(&serde_json::json!({"test":true})),
516            body_class: Some("dark"),
517            personality_css: Some(":root{--c:red}"),
518            body_prefix: Some(&crate::RawHtml::trusted("<nav>nav</nav>")),
519            render_mode: RenderMode::Phase2SsrReconcile,
520            ir_module: Some(&ir),
521            slots: Some(&slots),
522        });
523
524        assert!(page.html.contains("<title>Head Test</title>"));
525        assert!(page.html.contains("window.__FORMA_CONFIG__="));
526        assert!(page.html.contains("class=\"dark\""));
527        assert!(page.html.contains(":root{--c:red}"));
528        assert!(page.html.contains("<nav>nav</nav>"));
529        assert!(page.html.contains("<p>hello</p>"));
530        assert!(page.html.contains("data-forma-ssr"));
531    }
532
533    #[test]
534    fn phase2_with_slot_data() {
535        use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
536
537        // Build IR with DYN_TEXT referencing slot 0
538        let mut opcodes = Vec::new();
539        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <span>
540                                                             // DYN_TEXT opcode: 0x05 + slot_id(u16) + marker_id(u16)
541        opcodes.push(0x05);
542        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot_id = 0
543        opcodes.extend_from_slice(&0u16.to_le_bytes()); // marker_id = 0
544        opcodes.extend_from_slice(&encode_close_tag(0)); // </span>
545
546        let data = build_minimal_ir(
547            &["span", "name"],          // strings
548            &[(0, 1, 0x01, 0x00, &[])], // slot: id=0, name_str_idx=1, type=Text, source=Server
549            &opcodes,
550            &[],
551        );
552        let ir = IrModule::parse(&data).unwrap();
553
554        let mut slots = SlotData::new(1);
555        slots.set(0, SlotValue::Text("World".to_string()));
556
557        let manifest = empty_manifest();
558        let page = render_parts(&PageConfig {
559            title: "Slot Test",
560            route_pattern: "/test",
561            manifest: &manifest,
562            config_json: None,
563            body_class: None,
564            personality_css: None,
565            body_prefix: None,
566            render_mode: RenderMode::Phase2SsrReconcile,
567            ir_module: Some(&ir),
568            slots: Some(&slots),
569        });
570
571        assert!(page.html.contains("data-forma-ssr"));
572        // DYN_TEXT wraps content in marker comments for client reconciliation
573        assert!(page
574            .html
575            .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
576    }
577
578    // -- Security: escape_html tests -----------------------------------------
579
580    #[test]
581    fn escape_html_basic_entities() {
582        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
583        assert_eq!(escape_html("\"quotes\""), "&quot;quotes&quot;");
584        assert_eq!(escape_html("a&b"), "a&amp;b");
585        assert_eq!(escape_html("clean"), "clean");
586    }
587
588    #[test]
589    fn asset_filename_xss_is_escaped() {
590        // If a manifest somehow contained a malicious filename, it must be escaped
591        // in the HTML output rather than injected raw.
592        let mut routes = HashMap::new();
593        routes.insert(
594            "/test".to_string(),
595            RouteAssets {
596                mode: crate::RouteMode::Browser,
597                modulepreload: Vec::new(),
598                js: vec!["\"><script>alert(1)</script>".to_string()],
599                css: vec!["\"><script>alert(2)</script>".to_string()],
600                fonts: vec!["\"><script>alert(3)</script>".to_string()],
601                total_size_br: 0,
602                budget_warn_threshold: 204800,
603                ir: None,
604            },
605        );
606        let manifest = AssetManifest {
607            version: 2,
608            writer: "3.0.0".into(),
609            app: "ledger".into(),
610            aliases: HashMap::new(),
611            build: "test".to_string(),
612            assets: crate::render_contract::fixture_assets(&routes),
613            routes,
614        };
615
616        let page = render_parts(&PageConfig {
617            title: "XSS Test",
618            route_pattern: "/test",
619            manifest: &manifest,
620            config_json: None,
621            body_class: None,
622            personality_css: None,
623            body_prefix: None,
624            render_mode: RenderMode::Phase1ClientMount,
625            ir_module: None,
626            slots: None,
627        });
628
629        // The raw "<script>" must not appear unescaped in the output
630        assert!(
631            !page.html.contains("<script>alert("),
632            "asset filenames must be HTML-escaped, got: {}",
633            page.html
634        );
635        assert!(
636            page.html.contains("&lt;script&gt;"),
637            "escaped <script> should be present"
638        );
639    }
640
641    #[test]
642    fn body_class_with_quotes_is_escaped() {
643        let manifest = empty_manifest();
644        let page = render_parts(&PageConfig {
645            title: "Body Class Test",
646            route_pattern: "/test",
647            manifest: &manifest,
648            config_json: None,
649            body_class: Some("dark\" onload=\"alert(1)"),
650            personality_css: None,
651            body_prefix: None,
652            render_mode: RenderMode::Phase1ClientMount,
653            ir_module: None,
654            slots: None,
655        });
656
657        // The double quote in body_class must be escaped so the attacker
658        // cannot break out of the class="..." attribute.
659        // Raw: class="dark" onload="alert(1)"  (attribute injection)
660        // Safe: class="dark&quot; onload=&quot;alert(1)"  (single quoted attr value)
661        assert!(
662            page.html.contains("&quot;"),
663            "body_class double quote must be escaped"
664        );
665        assert!(
666            !page.html.contains(r#"class="dark" onload="#),
667            "body_class must not allow attribute breakout via unescaped double quote"
668        );
669    }
670
671    #[test]
672    fn personality_css_style_breakout_prevented() {
673        // Table-driven, because the old two-case literal replace passed a test
674        // that only checked the adjacent-literal shape. HTML tag names are
675        // case-insensitive and the tokenizer accepts whitespace and `/` before
676        // `>`, so every one of these used to escape the <style> element.
677        for css in [
678            "body{color:red}</style><script>alert(1)</script>",
679            "body{color:red}</StYlE><script>alert(1)</script>",
680            "body{color:red}</style ><script>alert(1)</script>",
681            "body{color:red}</style\t><script>alert(1)</script>",
682            "body{color:red}</style/><script>alert(1)</script>",
683            "body{color:red}</style\n><script>alert(1)</script>",
684            "body{color:red}</style\x0c><script>alert(1)</script>",
685            "body{color:red}<</style><script>alert(1)</script>",
686            "body{color:red}</sty</style><script>alert(1)</script>",
687        ] {
688            let manifest = empty_manifest();
689            let page = render_parts(&PageConfig {
690                title: "CSS Test",
691                route_pattern: "/test",
692                manifest: &manifest,
693                config_json: None,
694                body_class: None,
695                personality_css: Some(css),
696                body_prefix: None,
697                render_mode: RenderMode::Phase1ClientMount,
698                ir_module: None,
699                slots: None,
700            });
701
702            // Assert the real invariant, not one literal shape: the rendered
703            // <style> region must contain no `</` other than the closing tag
704            // the template itself appends. `</` is the ONLY way out of a
705            // RAWTEXT element, so if none survives, no end tag can be formed.
706            let start = page.html.find("<style").expect("style element");
707            let body_start = page.html[start..].find('>').expect("style open") + start + 1;
708            let end = page.html[body_start..]
709                .find("</style>")
710                .expect("style close")
711                + body_start;
712            let body = &page.html[body_start..end];
713            assert!(
714                !body.contains("</"),
715                "style body must contain no `</` for input {css:?}, got: {body}"
716            );
717        }
718    }
719
720    #[test]
721    fn personality_css_round_trips_legitimate_values() {
722        // `\/` is a valid CSS escape for `/`, so real CSS is unchanged in
723        // meaning. Range media queries in particular must survive: escaping
724        // `<` instead (the obvious alternative) turns `@media (width < 600px)`
725        // into an ident token that parses but never matches.
726        let manifest = empty_manifest();
727        let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
728                   .a::after{content:\"</div>\"}\
729                   .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
730        let page = render_parts(&PageConfig {
731            title: "CSS Test",
732            route_pattern: "/test",
733            manifest: &manifest,
734            config_json: None,
735            body_class: None,
736            personality_css: Some(css),
737            body_prefix: None,
738            render_mode: RenderMode::Phase1ClientMount,
739            ir_module: None,
740            slots: None,
741        });
742
743        // Untouched: no `<` before a `/`.
744        assert!(page.html.contains("@media (width < 600px)"));
745        assert!(page.html.contains("font:12px/1.4 sans-serif"));
746        assert!(page.html.contains("aspect-ratio:16/9"));
747        // Escaped but value-preserving: `content:"<\/div>"` computes to
748        // `</div>`.
749        assert!(page.html.contains("content:\"<\\/div>\""));
750    }
751
752    #[test]
753    fn config_json_emits_no_raw_angle_brackets() {
754        let manifest = empty_manifest();
755        let value = serde_json::json!({
756            "user": "</script><img src=x onerror=alert(1)>",
757            "note": "<!--<script>",
758        });
759        let page = render_parts(&PageConfig {
760            title: "Config Test",
761            route_pattern: "/test",
762            manifest: &manifest,
763            config_json: Some(&value),
764            body_class: None,
765            personality_css: None,
766            body_prefix: None,
767            render_mode: RenderMode::Phase1ClientMount,
768            ir_module: None,
769            slots: None,
770        });
771
772        let start = page
773            .html
774            .find("window.__FORMA_CONFIG__=")
775            .expect("config json script")
776            + "window.__FORMA_CONFIG__=".len();
777        let end = page.html[start..].find("</script>").expect("close") + start;
778        let json = &page.html[start..end];
779        assert!(
780            !json.contains('<'),
781            "config_json output must contain no raw '<', got: {json}"
782        );
783        // ...and it is value-preserving: < is a valid JSON escape.
784        let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
785        assert_eq!(parsed, value);
786    }
787
788    // -- Nonce plumbing (S11) -----------------------------------------------
789
790    #[test]
791    fn named_parts_expose_the_nonce_bound_to_the_document() {
792        let manifest = empty_manifest();
793        let page = render_parts(&PageConfig {
794            title: "Nonce Test",
795            route_pattern: "/test",
796            manifest: &manifest,
797            config_json: Some(&serde_json::json!({"x":1})),
798            body_class: None,
799            personality_css: Some("body{color:red}"),
800            body_prefix: None,
801            render_mode: RenderMode::Phase1ClientMount,
802            ir_module: None,
803            slots: None,
804        });
805
806        // No string-parsing the CSP to recover it.
807        assert!(policy(&page).contains(&format!("'nonce-{}'", page.nonce)));
808        assert!(page
809            .html
810            .contains(&format!("<script nonce=\"{}\">", page.nonce)));
811        assert!(page
812            .html
813            .contains(&format!("<style nonce=\"{}\">", page.nonce)));
814    }
815
816    #[test]
817    fn page_response_headers_are_no_store() {
818        // The HTML embeds a per-request nonce, so it must never be cached.
819        let manifest = empty_manifest();
820        let page = render_parts(&PageConfig {
821            title: "Header Test",
822            route_pattern: "/test",
823            manifest: &manifest,
824            config_json: None,
825            body_class: None,
826            personality_css: None,
827            body_prefix: None,
828            render_mode: RenderMode::Phase1ClientMount,
829            ir_module: None,
830            slots: None,
831        });
832
833        let headers = page.headers;
834        let get = |name: &str| {
835            headers
836                .iter()
837                .find(|(n, _)| n.as_str() == name)
838                .map(|(_, v)| v.to_str().unwrap().to_string())
839        };
840        assert_eq!(get("cache-control").as_deref(), Some("no-store"));
841        assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
842        assert!(get("content-security-policy").is_some());
843        assert!(get("referrer-policy").is_some());
844    }
845
846    // -- CSP / walker interlock (S4) ----------------------------------------
847
848    #[test]
849    fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
850        // A DYN_ATTR bound to `style` renders a style ATTRIBUTE, so the header
851        // this crate ships must permit attribute styles — otherwise the
852        // framework's default CSP breaks the framework's own output.
853        let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
854        opcodes.push(0x06); // DYN_ATTR
855        opcodes.extend_from_slice(&1u32.to_le_bytes()); // "style"
856        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot 0
857        let data = forma_ir::parser::test_helpers::build_minimal_ir(
858            &["div", "style", "css"],
859            &[(0, 2, 0x01, 0x00, &[])],
860            &opcodes,
861            &[],
862        );
863        let ir = IrModule::parse(&data).unwrap();
864        let mut slots = SlotData::new(1);
865        slots.set(0, SlotValue::Text("color:red".into()));
866
867        let manifest = empty_manifest();
868        let page = render_parts(&PageConfig {
869            title: "Style Attr",
870            route_pattern: "/test",
871            manifest: &manifest,
872            config_json: None,
873            body_class: None,
874            personality_css: None,
875            body_prefix: None,
876            render_mode: RenderMode::Phase2SsrReconcile,
877            ir_module: Some(&ir),
878            slots: Some(&slots),
879        });
880
881        assert!(
882            page.html.contains("style=\"color:red\""),
883            "walker must emit the style attribute: {}",
884            page.html
885        );
886        assert!(
887            policy(&page).contains("style-src-attr 'unsafe-inline'"),
888            "CSP must permit the attribute style the walker just emitted: {}",
889            policy(&page)
890        );
891    }
892}