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