1use crate::csp::CspNonce;
2use crate::head::HeadLink;
3use crate::render::{RenderCtx, RenderError};
4use crate::types::{AssetManifest, PageConfig};
5
6struct ResolvedAssets {
8 fonts: Vec<String>,
9 css_urls: Vec<Resource>,
10 js_urls: Vec<Resource>,
11}
12
13struct Resource {
15 url: String,
16 integrity: String,
17}
18
19pub(crate) fn escape_html(s: &str) -> String {
21 s.replace('&', "&")
22 .replace('<', "<")
23 .replace('>', ">")
24 .replace('"', """)
25}
26
27pub(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
37fn 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 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 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 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 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 for link in links {
100 link.write(&mut head);
101 }
102
103 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
128fn 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
146fn 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
167struct 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 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 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 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
233pub(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 #[cfg(feature = "navigation")]
245 let navigation_metadata = crate::navigation::initial_metadata(config, ctx)?;
246 #[cfg(not(feature = "navigation"))]
247 let navigation_metadata = String::new();
248 write_document_parts(
249 config,
250 nonce,
251 ssr_body,
252 island_state.map(forma_ir::IslandState::as_json),
253 ctx,
254 max_output_bytes,
255 counts,
256 &navigation_metadata,
257 false,
258 )
259}
260
261#[allow(clippy::too_many_arguments)]
262pub(crate) fn write_document_parts(
263 config: &PageConfig,
264 nonce: &CspNonce,
265 ssr_body: Option<&str>,
266 island_state: Option<&str>,
267 ctx: &RenderCtx,
268 max_output_bytes: usize,
269 counts: crate::RenderCounts,
270 navigation_metadata: &str,
271 owner_branch: bool,
272) -> Result<String, RenderError> {
273 let nonce_attr = escape_html(nonce.as_str());
276 let assets = resolve_assets(config.manifest, config.route_pattern)?;
277 let mut preloads = Vec::new();
278 let mut seen = std::collections::HashSet::new();
279 for file in ctx.module_preloads(config.route_pattern) {
280 let url = format!("/_assets/{file}");
281 if assets.js_urls.iter().any(|asset| asset.url == url) || !seen.insert(file) {
282 continue;
283 }
284 preloads.push(resource(config.manifest, file, config.route_pattern)?);
285 }
286 let head = build_head(
287 config.title,
288 &nonce_attr,
289 &assets,
290 config.personality_css,
291 &preloads,
292 ctx.head_links(),
293 );
294 let parts = build_body_parts(&nonce_attr, config, &assets.js_urls);
295 let static_route = config
300 .manifest
301 .route(config.route_pattern)
302 .is_some_and(crate::RouteAssets::is_static_ssr);
303 let build_attr = if static_route {
304 String::new()
305 } else {
306 format!(
307 " data-forma-build=\"{}\"",
308 escape_html(&config.manifest.build)
309 )
310 };
311
312 let fragments = [
316 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n",
317 head.as_str(),
318 if island_state.is_some() {
319 "<script type=\"application/json\" data-island-state>"
320 } else {
321 ""
322 },
323 island_state.unwrap_or(""),
324 if island_state.is_some() {
325 "</script>\n"
326 } else {
327 ""
328 },
329 navigation_metadata,
330 "</head>\n<body",
331 parts.body_class_attr.as_str(),
332 ">\n",
333 parts.body_prefix.as_str(),
334 "<div id=\"app\"",
335 build_attr.as_str(),
336 if owner_branch {
337 " data-forma-router-root"
338 } else {
339 ""
340 },
341 if ssr_body.is_some() {
342 if static_route {
343 ">"
344 } else {
345 " data-forma-ssr>"
346 }
347 } else {
348 "></div>"
349 },
350 ssr_body.unwrap_or(""),
351 if ssr_body.is_some() { "</div>" } else { "" },
352 "\n",
353 parts.config_json_tag.as_str(),
354 parts.page_js_tag.as_str(),
355 "\n",
356 parts.sw_script.as_str(),
357 "</body>\n</html>",
358 ];
359 let total = fragments
360 .iter()
361 .try_fold(0usize, |total, part| total.checked_add(part.len()));
362 let Some(total) = total.filter(|total| *total <= max_output_bytes) else {
363 return Err(RenderError::Budget {
364 route: config.route_pattern.into(),
365 component_path: Vec::new(),
366 counts,
367 cause: forma_ir::IrError::OutputLimitExceeded {
368 limit: max_output_bytes,
369 },
370 });
371 };
372 let mut html = String::with_capacity(total);
373 for fragment in fragments {
374 html.push_str(fragment);
375 }
376
377 Ok(html)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::render_contract::{RenderFixture as PageConfig, RenderPath as RenderMode};
384 use crate::types::RouteAssets;
385 use forma_ir::parser::IrModule;
386 use forma_ir::slot::{SlotData, SlotValue};
387 use std::collections::HashMap;
388
389 fn render_parts(config: &PageConfig) -> crate::PageParts {
390 let ctx = RenderCtx::new();
391 match config.render_mode {
392 RenderMode::Phase1ClientMount => crate::render_contract::render_shell(config, &ctx),
393 RenderMode::Phase2SsrReconcile => crate::render_contract::render_ssr(config, &ctx),
394 }
395 .expect("valid fixture renders")
396 .into_parts_unchecked()
397 }
398 fn policy(page: &crate::PageParts) -> &str {
399 page.headers
400 .iter()
401 .find(|(name, _)| name == "content-security-policy")
402 .unwrap()
403 .1
404 .to_str()
405 .unwrap()
406 }
407
408 fn empty_manifest() -> AssetManifest {
410 AssetManifest {
411 version: 2,
412 writer: "3.0.0".into(),
413 app: "ledger".into(),
414 aliases: HashMap::new(),
415 build: "test".to_string(),
416 assets: HashMap::new(),
417 routes: HashMap::new(),
418 }
419 }
420
421 #[test]
422 fn a_legacy_manifest_is_refused_before_removed_wasm_fields_can_be_used() {
423 let error = serde_json::from_value::<AssetManifest>(serde_json::json!({
424 "version":1,"build_hash":"legacy","assets":{},"routes":{},
425 "wasm":{"loader":"legacy-loader.js","binary":"legacy.wasm"}
426 }))
427 .unwrap_err();
428 assert!(error.to_string().contains("MANIFEST-101"));
429 }
430
431 fn hello_ir_module() -> IrModule {
433 use forma_ir::parser::test_helpers::{
434 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
435 };
436
437 let mut opcodes = Vec::new();
438 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.extend_from_slice(&encode_text(1)); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
443 IrModule::parse(&data).unwrap()
444 }
445
446 #[test]
447 fn phase1_renders_empty_app_div() {
448 let manifest = empty_manifest();
449 let page = render_parts(&PageConfig {
450 title: "Test",
451 route_pattern: "/test",
452 manifest: &manifest,
453 config_json: None,
454 body_class: None,
455 personality_css: None,
456 body_prefix: None,
457 render_mode: RenderMode::Phase1ClientMount,
458 ir_module: None,
459 slots: None,
460 });
461
462 assert!(page
464 .html
465 .contains("<div id=\"app\" data-forma-build=\"test\"></div>"));
466 assert!(!page.html.contains("data-forma-ssr"));
468 }
469
470 #[test]
471 fn phase2_renders_ssr_content_with_data_attr() {
472 let manifest = empty_manifest();
473 let ir = hello_ir_module();
474 let slots = SlotData::new(0);
475
476 let page = render_parts(&PageConfig {
477 title: "SSR Test",
478 route_pattern: "/test",
479 manifest: &manifest,
480 config_json: None,
481 body_class: None,
482 personality_css: None,
483 body_prefix: None,
484 render_mode: RenderMode::Phase2SsrReconcile,
485 ir_module: Some(&ir),
486 slots: Some(&slots),
487 });
488
489 assert!(page.html.contains("data-forma-ssr"));
490 assert!(page.html.contains("<p>hello</p>"));
491 assert!(page.html.contains(
492 "<div id=\"app\" data-forma-build=\"test\" data-forma-ssr><p>hello</p></div>"
493 ));
494 }
495
496 #[test]
502 fn the_root_carries_the_manifests_build_escaped_on_both_forms() {
503 let mut manifest = empty_manifest();
504 manifest.build = "5f0c9a1e7d2b4c86".into();
505 let ir = hello_ir_module();
506 let slots = SlotData::new(0);
507 for (mode, ir_module, slots) in [
508 (RenderMode::Phase1ClientMount, None, None),
509 (RenderMode::Phase2SsrReconcile, Some(&ir), Some(&slots)),
510 ] {
511 let page = render_parts(&PageConfig {
512 title: "Identity",
513 route_pattern: "/test",
514 manifest: &manifest,
515 config_json: None,
516 body_class: None,
517 personality_css: None,
518 body_prefix: None,
519 render_mode: mode,
520 ir_module,
521 slots,
522 });
523 assert_eq!(
524 page.html.matches("data-forma-build=").count(),
525 1,
526 "exactly one stamp: {}",
527 page.html
528 );
529 assert!(page
530 .html
531 .contains("<div id=\"app\" data-forma-build=\"5f0c9a1e7d2b4c86\""));
532 }
533 manifest.build = "a\"b<c>&d".into();
534 let page = render_parts(&PageConfig {
535 title: "Identity",
536 route_pattern: "/test",
537 manifest: &manifest,
538 config_json: None,
539 body_class: None,
540 personality_css: None,
541 body_prefix: None,
542 render_mode: RenderMode::Phase1ClientMount,
543 ir_module: None,
544 slots: None,
545 });
546 assert!(page
547 .html
548 .contains("data-forma-build=\"a"b<c>&d\""));
549 assert!(!page.html.contains("a\"b<c>"));
550 }
551
552 #[test]
553 fn phase2_ssr_preserves_head_content() {
554 let manifest = empty_manifest();
555 let ir = hello_ir_module();
556 let slots = SlotData::new(0);
557
558 let page = render_parts(&PageConfig {
559 title: "Head Test",
560 route_pattern: "/test",
561 manifest: &manifest,
562 config_json: Some(&serde_json::json!({"test":true})),
563 body_class: Some("dark"),
564 personality_css: Some(":root{--c:red}"),
565 body_prefix: Some(&crate::RawHtml::trusted("<nav>nav</nav>")),
566 render_mode: RenderMode::Phase2SsrReconcile,
567 ir_module: Some(&ir),
568 slots: Some(&slots),
569 });
570
571 assert!(page.html.contains("<title>Head Test</title>"));
572 assert!(page.html.contains("window.__FORMA_CONFIG__="));
573 assert!(page.html.contains("class=\"dark\""));
574 assert!(page.html.contains(":root{--c:red}"));
575 assert!(page.html.contains("<nav>nav</nav>"));
576 assert!(page.html.contains("<p>hello</p>"));
577 assert!(page.html.contains("data-forma-ssr"));
578 }
579
580 #[test]
581 fn phase2_with_slot_data() {
582 use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
583
584 let mut opcodes = Vec::new();
586 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
589 opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(
594 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &opcodes,
597 &[],
598 );
599 let ir = IrModule::parse(&data).unwrap();
600
601 let mut slots = SlotData::new(1);
602 slots.set(0, SlotValue::Text("World".to_string()));
603
604 let manifest = empty_manifest();
605 let page = render_parts(&PageConfig {
606 title: "Slot Test",
607 route_pattern: "/test",
608 manifest: &manifest,
609 config_json: None,
610 body_class: None,
611 personality_css: None,
612 body_prefix: None,
613 render_mode: RenderMode::Phase2SsrReconcile,
614 ir_module: Some(&ir),
615 slots: Some(&slots),
616 });
617
618 assert!(page.html.contains("data-forma-ssr"));
619 assert!(page
621 .html
622 .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
623 }
624
625 #[test]
628 fn escape_html_basic_entities() {
629 assert_eq!(escape_html("<script>"), "<script>");
630 assert_eq!(escape_html("\"quotes\""), ""quotes"");
631 assert_eq!(escape_html("a&b"), "a&b");
632 assert_eq!(escape_html("clean"), "clean");
633 }
634
635 #[test]
636 fn asset_filename_xss_is_escaped() {
637 let mut routes = HashMap::new();
640 routes.insert(
641 "/test".to_string(),
642 RouteAssets {
643 #[cfg(feature = "navigation")]
644 navigation: None,
645 mode: crate::RouteMode::Browser,
646 modulepreload: Vec::new(),
647 js: vec!["\"><script>alert(1)</script>".to_string()],
648 css: vec!["\"><script>alert(2)</script>".to_string()],
649 fonts: vec!["\"><script>alert(3)</script>".to_string()],
650 total_size_br: 0,
651 budget_warn_threshold: 204800,
652 ir: None,
653 },
654 );
655 let manifest = AssetManifest {
656 version: 2,
657 writer: "3.0.0".into(),
658 app: "ledger".into(),
659 aliases: HashMap::new(),
660 build: "test".to_string(),
661 assets: crate::render_contract::fixture_assets(&routes),
662 routes,
663 };
664
665 let page = render_parts(&PageConfig {
666 title: "XSS Test",
667 route_pattern: "/test",
668 manifest: &manifest,
669 config_json: None,
670 body_class: None,
671 personality_css: None,
672 body_prefix: None,
673 render_mode: RenderMode::Phase1ClientMount,
674 ir_module: None,
675 slots: None,
676 });
677
678 assert!(
680 !page.html.contains("<script>alert("),
681 "asset filenames must be HTML-escaped, got: {}",
682 page.html
683 );
684 assert!(
685 page.html.contains("<script>"),
686 "escaped <script> should be present"
687 );
688 }
689
690 #[test]
691 fn body_class_with_quotes_is_escaped() {
692 let manifest = empty_manifest();
693 let page = render_parts(&PageConfig {
694 title: "Body Class Test",
695 route_pattern: "/test",
696 manifest: &manifest,
697 config_json: None,
698 body_class: Some("dark\" onload=\"alert(1)"),
699 personality_css: None,
700 body_prefix: None,
701 render_mode: RenderMode::Phase1ClientMount,
702 ir_module: None,
703 slots: None,
704 });
705
706 assert!(
711 page.html.contains("""),
712 "body_class double quote must be escaped"
713 );
714 assert!(
715 !page.html.contains(r#"class="dark" onload="#),
716 "body_class must not allow attribute breakout via unescaped double quote"
717 );
718 }
719
720 #[test]
721 fn personality_css_style_breakout_prevented() {
722 for css in [
727 "body{color:red}</style><script>alert(1)</script>",
728 "body{color:red}</StYlE><script>alert(1)</script>",
729 "body{color:red}</style ><script>alert(1)</script>",
730 "body{color:red}</style\t><script>alert(1)</script>",
731 "body{color:red}</style/><script>alert(1)</script>",
732 "body{color:red}</style\n><script>alert(1)</script>",
733 "body{color:red}</style\x0c><script>alert(1)</script>",
734 "body{color:red}<</style><script>alert(1)</script>",
735 "body{color:red}</sty</style><script>alert(1)</script>",
736 ] {
737 let manifest = empty_manifest();
738 let page = render_parts(&PageConfig {
739 title: "CSS Test",
740 route_pattern: "/test",
741 manifest: &manifest,
742 config_json: None,
743 body_class: None,
744 personality_css: Some(css),
745 body_prefix: None,
746 render_mode: RenderMode::Phase1ClientMount,
747 ir_module: None,
748 slots: None,
749 });
750
751 let start = page.html.find("<style").expect("style element");
756 let body_start = page.html[start..].find('>').expect("style open") + start + 1;
757 let end = page.html[body_start..]
758 .find("</style>")
759 .expect("style close")
760 + body_start;
761 let body = &page.html[body_start..end];
762 assert!(
763 !body.contains("</"),
764 "style body must contain no `</` for input {css:?}, got: {body}"
765 );
766 }
767 }
768
769 #[test]
770 fn personality_css_round_trips_legitimate_values() {
771 let manifest = empty_manifest();
776 let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
777 .a::after{content:\"</div>\"}\
778 .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
779 let page = render_parts(&PageConfig {
780 title: "CSS Test",
781 route_pattern: "/test",
782 manifest: &manifest,
783 config_json: None,
784 body_class: None,
785 personality_css: Some(css),
786 body_prefix: None,
787 render_mode: RenderMode::Phase1ClientMount,
788 ir_module: None,
789 slots: None,
790 });
791
792 assert!(page.html.contains("@media (width < 600px)"));
794 assert!(page.html.contains("font:12px/1.4 sans-serif"));
795 assert!(page.html.contains("aspect-ratio:16/9"));
796 assert!(page.html.contains("content:\"<\\/div>\""));
799 }
800
801 #[test]
802 fn config_json_emits_no_raw_angle_brackets() {
803 let manifest = empty_manifest();
804 let value = serde_json::json!({
805 "user": "</script><img src=x onerror=alert(1)>",
806 "note": "<!--<script>",
807 });
808 let page = render_parts(&PageConfig {
809 title: "Config Test",
810 route_pattern: "/test",
811 manifest: &manifest,
812 config_json: Some(&value),
813 body_class: None,
814 personality_css: None,
815 body_prefix: None,
816 render_mode: RenderMode::Phase1ClientMount,
817 ir_module: None,
818 slots: None,
819 });
820
821 let start = page
822 .html
823 .find("window.__FORMA_CONFIG__=")
824 .expect("config json script")
825 + "window.__FORMA_CONFIG__=".len();
826 let end = page.html[start..].find("</script>").expect("close") + start;
827 let json = &page.html[start..end];
828 assert!(
829 !json.contains('<'),
830 "config_json output must contain no raw '<', got: {json}"
831 );
832 let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
834 assert_eq!(parsed, value);
835 }
836
837 #[test]
840 fn named_parts_expose_the_nonce_bound_to_the_document() {
841 let manifest = empty_manifest();
842 let page = render_parts(&PageConfig {
843 title: "Nonce Test",
844 route_pattern: "/test",
845 manifest: &manifest,
846 config_json: Some(&serde_json::json!({"x":1})),
847 body_class: None,
848 personality_css: Some("body{color:red}"),
849 body_prefix: None,
850 render_mode: RenderMode::Phase1ClientMount,
851 ir_module: None,
852 slots: None,
853 });
854
855 assert!(policy(&page).contains(&format!("'nonce-{}'", page.nonce)));
857 assert!(page
858 .html
859 .contains(&format!("<script nonce=\"{}\">", page.nonce)));
860 assert!(page
861 .html
862 .contains(&format!("<style nonce=\"{}\">", page.nonce)));
863 }
864
865 #[test]
866 fn page_response_headers_are_no_store() {
867 let manifest = empty_manifest();
869 let page = render_parts(&PageConfig {
870 title: "Header Test",
871 route_pattern: "/test",
872 manifest: &manifest,
873 config_json: None,
874 body_class: None,
875 personality_css: None,
876 body_prefix: None,
877 render_mode: RenderMode::Phase1ClientMount,
878 ir_module: None,
879 slots: None,
880 });
881
882 let headers = page.headers;
883 let get = |name: &str| {
884 headers
885 .iter()
886 .find(|(n, _)| n.as_str() == name)
887 .map(|(_, v)| v.to_str().unwrap().to_string())
888 };
889 assert_eq!(get("cache-control").as_deref(), Some("no-store"));
890 assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
891 assert!(get("content-security-policy").is_some());
892 assert!(get("referrer-policy").is_some());
893 }
894
895 #[test]
898 fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
899 let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
903 opcodes.push(0x06); opcodes.extend_from_slice(&1u32.to_le_bytes()); opcodes.extend_from_slice(&0u16.to_le_bytes()); let data = forma_ir::parser::test_helpers::build_minimal_ir(
907 &["div", "style", "css"],
908 &[(0, 2, 0x01, 0x00, &[])],
909 &opcodes,
910 &[],
911 );
912 let ir = IrModule::parse(&data).unwrap();
913 let mut slots = SlotData::new(1);
914 slots.set(0, SlotValue::Text("color:red".into()));
915
916 let manifest = empty_manifest();
917 let page = render_parts(&PageConfig {
918 title: "Style Attr",
919 route_pattern: "/test",
920 manifest: &manifest,
921 config_json: None,
922 body_class: None,
923 personality_css: None,
924 body_prefix: None,
925 render_mode: RenderMode::Phase2SsrReconcile,
926 ir_module: Some(&ir),
927 slots: Some(&slots),
928 });
929
930 assert!(
931 page.html.contains("style=\"color:red\""),
932 "walker must emit the style attribute: {}",
933 page.html
934 );
935 assert!(
936 policy(&page).contains("style-src-attr 'unsafe-inline'"),
937 "CSP must permit the attribute style the walker just emitted: {}",
938 policy(&page)
939 );
940 }
941}