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 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 let static_route = config
271 .manifest
272 .route(config.route_pattern)
273 .is_some_and(crate::RouteAssets::is_static_ssr);
274 let build_attr = if static_route {
275 String::new()
276 } else {
277 format!(
278 " data-forma-build=\"{}\"",
279 escape_html(&config.manifest.build)
280 )
281 };
282
283 let fragments = [
287 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n",
288 head.as_str(),
289 if island_state.is_some() {
290 "<script type=\"application/json\" data-island-state>"
291 } else {
292 ""
293 },
294 island_state.map_or("", forma_ir::IslandState::as_json),
295 if island_state.is_some() {
296 "</script>\n"
297 } else {
298 ""
299 },
300 "</head>\n<body",
301 parts.body_class_attr.as_str(),
302 ">\n",
303 parts.body_prefix.as_str(),
304 "<div id=\"app\"",
305 build_attr.as_str(),
306 if ssr_body.is_some() {
307 if static_route {
308 ">"
309 } else {
310 " data-forma-ssr>"
311 }
312 } else {
313 "></div>"
314 },
315 ssr_body.unwrap_or(""),
316 if ssr_body.is_some() { "</div>" } else { "" },
317 "\n",
318 parts.config_json_tag.as_str(),
319 parts.page_js_tag.as_str(),
320 "\n",
321 parts.sw_script.as_str(),
322 "</body>\n</html>",
323 ];
324 let total = fragments
325 .iter()
326 .try_fold(0usize, |total, part| total.checked_add(part.len()));
327 let Some(total) = total.filter(|total| *total <= max_output_bytes) else {
328 return Err(RenderError::Budget {
329 route: config.route_pattern.into(),
330 component_path: Vec::new(),
331 counts,
332 cause: forma_ir::IrError::OutputLimitExceeded {
333 limit: max_output_bytes,
334 },
335 });
336 };
337 let mut html = String::with_capacity(total);
338 for fragment in fragments {
339 html.push_str(fragment);
340 }
341
342 Ok(html)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::render_contract::{RenderFixture as PageConfig, RenderPath as RenderMode};
349 use crate::types::RouteAssets;
350 use forma_ir::parser::IrModule;
351 use forma_ir::slot::{SlotData, SlotValue};
352 use std::collections::HashMap;
353
354 fn render_parts(config: &PageConfig) -> crate::PageParts {
355 let ctx = RenderCtx::new();
356 match config.render_mode {
357 RenderMode::Phase1ClientMount => crate::render_contract::render_shell(config, &ctx),
358 RenderMode::Phase2SsrReconcile => crate::render_contract::render_ssr(config, &ctx),
359 }
360 .expect("valid fixture renders")
361 .into_parts_unchecked()
362 }
363 fn policy(page: &crate::PageParts) -> &str {
364 page.headers
365 .iter()
366 .find(|(name, _)| name == "content-security-policy")
367 .unwrap()
368 .1
369 .to_str()
370 .unwrap()
371 }
372
373 fn empty_manifest() -> AssetManifest {
375 AssetManifest {
376 version: 2,
377 writer: "3.0.0".into(),
378 app: "ledger".into(),
379 aliases: HashMap::new(),
380 build: "test".to_string(),
381 assets: HashMap::new(),
382 routes: HashMap::new(),
383 }
384 }
385
386 #[test]
387 fn a_legacy_manifest_is_refused_before_removed_wasm_fields_can_be_used() {
388 let error = serde_json::from_value::<AssetManifest>(serde_json::json!({
389 "version":1,"build_hash":"legacy","assets":{},"routes":{},
390 "wasm":{"loader":"legacy-loader.js","binary":"legacy.wasm"}
391 }))
392 .unwrap_err();
393 assert!(error.to_string().contains("MANIFEST-101"));
394 }
395
396 fn hello_ir_module() -> IrModule {
398 use forma_ir::parser::test_helpers::{
399 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
400 };
401
402 let mut opcodes = Vec::new();
403 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, &[]);
408 IrModule::parse(&data).unwrap()
409 }
410
411 #[test]
412 fn phase1_renders_empty_app_div() {
413 let manifest = empty_manifest();
414 let page = render_parts(&PageConfig {
415 title: "Test",
416 route_pattern: "/test",
417 manifest: &manifest,
418 config_json: None,
419 body_class: None,
420 personality_css: None,
421 body_prefix: None,
422 render_mode: RenderMode::Phase1ClientMount,
423 ir_module: None,
424 slots: None,
425 });
426
427 assert!(page
429 .html
430 .contains("<div id=\"app\" data-forma-build=\"test\"></div>"));
431 assert!(!page.html.contains("data-forma-ssr"));
433 }
434
435 #[test]
436 fn phase2_renders_ssr_content_with_data_attr() {
437 let manifest = empty_manifest();
438 let ir = hello_ir_module();
439 let slots = SlotData::new(0);
440
441 let page = render_parts(&PageConfig {
442 title: "SSR Test",
443 route_pattern: "/test",
444 manifest: &manifest,
445 config_json: None,
446 body_class: None,
447 personality_css: None,
448 body_prefix: None,
449 render_mode: RenderMode::Phase2SsrReconcile,
450 ir_module: Some(&ir),
451 slots: Some(&slots),
452 });
453
454 assert!(page.html.contains("data-forma-ssr"));
455 assert!(page.html.contains("<p>hello</p>"));
456 assert!(page.html.contains(
457 "<div id=\"app\" data-forma-build=\"test\" data-forma-ssr><p>hello</p></div>"
458 ));
459 }
460
461 #[test]
467 fn the_root_carries_the_manifests_build_escaped_on_both_forms() {
468 let mut manifest = empty_manifest();
469 manifest.build = "5f0c9a1e7d2b4c86".into();
470 let ir = hello_ir_module();
471 let slots = SlotData::new(0);
472 for (mode, ir_module, slots) in [
473 (RenderMode::Phase1ClientMount, None, None),
474 (RenderMode::Phase2SsrReconcile, Some(&ir), Some(&slots)),
475 ] {
476 let page = render_parts(&PageConfig {
477 title: "Identity",
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: mode,
485 ir_module,
486 slots,
487 });
488 assert_eq!(
489 page.html.matches("data-forma-build=").count(),
490 1,
491 "exactly one stamp: {}",
492 page.html
493 );
494 assert!(page
495 .html
496 .contains("<div id=\"app\" data-forma-build=\"5f0c9a1e7d2b4c86\""));
497 }
498 manifest.build = "a\"b<c>&d".into();
499 let page = render_parts(&PageConfig {
500 title: "Identity",
501 route_pattern: "/test",
502 manifest: &manifest,
503 config_json: None,
504 body_class: None,
505 personality_css: None,
506 body_prefix: None,
507 render_mode: RenderMode::Phase1ClientMount,
508 ir_module: None,
509 slots: None,
510 });
511 assert!(page
512 .html
513 .contains("data-forma-build=\"a"b<c>&d\""));
514 assert!(!page.html.contains("a\"b<c>"));
515 }
516
517 #[test]
518 fn phase2_ssr_preserves_head_content() {
519 let manifest = empty_manifest();
520 let ir = hello_ir_module();
521 let slots = SlotData::new(0);
522
523 let page = render_parts(&PageConfig {
524 title: "Head Test",
525 route_pattern: "/test",
526 manifest: &manifest,
527 config_json: Some(&serde_json::json!({"test":true})),
528 body_class: Some("dark"),
529 personality_css: Some(":root{--c:red}"),
530 body_prefix: Some(&crate::RawHtml::trusted("<nav>nav</nav>")),
531 render_mode: RenderMode::Phase2SsrReconcile,
532 ir_module: Some(&ir),
533 slots: Some(&slots),
534 });
535
536 assert!(page.html.contains("<title>Head Test</title>"));
537 assert!(page.html.contains("window.__FORMA_CONFIG__="));
538 assert!(page.html.contains("class=\"dark\""));
539 assert!(page.html.contains(":root{--c:red}"));
540 assert!(page.html.contains("<nav>nav</nav>"));
541 assert!(page.html.contains("<p>hello</p>"));
542 assert!(page.html.contains("data-forma-ssr"));
543 }
544
545 #[test]
546 fn phase2_with_slot_data() {
547 use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
548
549 let mut opcodes = Vec::new();
551 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
554 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(
559 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &opcodes,
562 &[],
563 );
564 let ir = IrModule::parse(&data).unwrap();
565
566 let mut slots = SlotData::new(1);
567 slots.set(0, SlotValue::Text("World".to_string()));
568
569 let manifest = empty_manifest();
570 let page = render_parts(&PageConfig {
571 title: "Slot Test",
572 route_pattern: "/test",
573 manifest: &manifest,
574 config_json: None,
575 body_class: None,
576 personality_css: None,
577 body_prefix: None,
578 render_mode: RenderMode::Phase2SsrReconcile,
579 ir_module: Some(&ir),
580 slots: Some(&slots),
581 });
582
583 assert!(page.html.contains("data-forma-ssr"));
584 assert!(page
586 .html
587 .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
588 }
589
590 #[test]
593 fn escape_html_basic_entities() {
594 assert_eq!(escape_html("<script>"), "<script>");
595 assert_eq!(escape_html("\"quotes\""), ""quotes"");
596 assert_eq!(escape_html("a&b"), "a&b");
597 assert_eq!(escape_html("clean"), "clean");
598 }
599
600 #[test]
601 fn asset_filename_xss_is_escaped() {
602 let mut routes = HashMap::new();
605 routes.insert(
606 "/test".to_string(),
607 RouteAssets {
608 mode: crate::RouteMode::Browser,
609 modulepreload: Vec::new(),
610 js: vec!["\"><script>alert(1)</script>".to_string()],
611 css: vec!["\"><script>alert(2)</script>".to_string()],
612 fonts: vec!["\"><script>alert(3)</script>".to_string()],
613 total_size_br: 0,
614 budget_warn_threshold: 204800,
615 ir: None,
616 },
617 );
618 let manifest = AssetManifest {
619 version: 2,
620 writer: "3.0.0".into(),
621 app: "ledger".into(),
622 aliases: HashMap::new(),
623 build: "test".to_string(),
624 assets: crate::render_contract::fixture_assets(&routes),
625 routes,
626 };
627
628 let page = render_parts(&PageConfig {
629 title: "XSS Test",
630 route_pattern: "/test",
631 manifest: &manifest,
632 config_json: None,
633 body_class: None,
634 personality_css: None,
635 body_prefix: None,
636 render_mode: RenderMode::Phase1ClientMount,
637 ir_module: None,
638 slots: None,
639 });
640
641 assert!(
643 !page.html.contains("<script>alert("),
644 "asset filenames must be HTML-escaped, got: {}",
645 page.html
646 );
647 assert!(
648 page.html.contains("<script>"),
649 "escaped <script> should be present"
650 );
651 }
652
653 #[test]
654 fn body_class_with_quotes_is_escaped() {
655 let manifest = empty_manifest();
656 let page = render_parts(&PageConfig {
657 title: "Body Class Test",
658 route_pattern: "/test",
659 manifest: &manifest,
660 config_json: None,
661 body_class: Some("dark\" onload=\"alert(1)"),
662 personality_css: None,
663 body_prefix: None,
664 render_mode: RenderMode::Phase1ClientMount,
665 ir_module: None,
666 slots: None,
667 });
668
669 assert!(
674 page.html.contains("""),
675 "body_class double quote must be escaped"
676 );
677 assert!(
678 !page.html.contains(r#"class="dark" onload="#),
679 "body_class must not allow attribute breakout via unescaped double quote"
680 );
681 }
682
683 #[test]
684 fn personality_css_style_breakout_prevented() {
685 for css in [
690 "body{color:red}</style><script>alert(1)</script>",
691 "body{color:red}</StYlE><script>alert(1)</script>",
692 "body{color:red}</style ><script>alert(1)</script>",
693 "body{color:red}</style\t><script>alert(1)</script>",
694 "body{color:red}</style/><script>alert(1)</script>",
695 "body{color:red}</style\n><script>alert(1)</script>",
696 "body{color:red}</style\x0c><script>alert(1)</script>",
697 "body{color:red}<</style><script>alert(1)</script>",
698 "body{color:red}</sty</style><script>alert(1)</script>",
699 ] {
700 let manifest = empty_manifest();
701 let page = render_parts(&PageConfig {
702 title: "CSS Test",
703 route_pattern: "/test",
704 manifest: &manifest,
705 config_json: None,
706 body_class: None,
707 personality_css: Some(css),
708 body_prefix: None,
709 render_mode: RenderMode::Phase1ClientMount,
710 ir_module: None,
711 slots: None,
712 });
713
714 let start = page.html.find("<style").expect("style element");
719 let body_start = page.html[start..].find('>').expect("style open") + start + 1;
720 let end = page.html[body_start..]
721 .find("</style>")
722 .expect("style close")
723 + body_start;
724 let body = &page.html[body_start..end];
725 assert!(
726 !body.contains("</"),
727 "style body must contain no `</` for input {css:?}, got: {body}"
728 );
729 }
730 }
731
732 #[test]
733 fn personality_css_round_trips_legitimate_values() {
734 let manifest = empty_manifest();
739 let css = "@media (width < 600px){#t{color:rgb(1,2,3)}}\
740 .a::after{content:\"</div>\"}\
741 .b{font:12px/1.4 sans-serif;aspect-ratio:16/9}";
742 let page = render_parts(&PageConfig {
743 title: "CSS Test",
744 route_pattern: "/test",
745 manifest: &manifest,
746 config_json: None,
747 body_class: None,
748 personality_css: Some(css),
749 body_prefix: None,
750 render_mode: RenderMode::Phase1ClientMount,
751 ir_module: None,
752 slots: None,
753 });
754
755 assert!(page.html.contains("@media (width < 600px)"));
757 assert!(page.html.contains("font:12px/1.4 sans-serif"));
758 assert!(page.html.contains("aspect-ratio:16/9"));
759 assert!(page.html.contains("content:\"<\\/div>\""));
762 }
763
764 #[test]
765 fn config_json_emits_no_raw_angle_brackets() {
766 let manifest = empty_manifest();
767 let value = serde_json::json!({
768 "user": "</script><img src=x onerror=alert(1)>",
769 "note": "<!--<script>",
770 });
771 let page = render_parts(&PageConfig {
772 title: "Config Test",
773 route_pattern: "/test",
774 manifest: &manifest,
775 config_json: Some(&value),
776 body_class: None,
777 personality_css: None,
778 body_prefix: None,
779 render_mode: RenderMode::Phase1ClientMount,
780 ir_module: None,
781 slots: None,
782 });
783
784 let start = page
785 .html
786 .find("window.__FORMA_CONFIG__=")
787 .expect("config json script")
788 + "window.__FORMA_CONFIG__=".len();
789 let end = page.html[start..].find("</script>").expect("close") + start;
790 let json = &page.html[start..end];
791 assert!(
792 !json.contains('<'),
793 "config_json output must contain no raw '<', got: {json}"
794 );
795 let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
797 assert_eq!(parsed, value);
798 }
799
800 #[test]
803 fn named_parts_expose_the_nonce_bound_to_the_document() {
804 let manifest = empty_manifest();
805 let page = render_parts(&PageConfig {
806 title: "Nonce Test",
807 route_pattern: "/test",
808 manifest: &manifest,
809 config_json: Some(&serde_json::json!({"x":1})),
810 body_class: None,
811 personality_css: Some("body{color:red}"),
812 body_prefix: None,
813 render_mode: RenderMode::Phase1ClientMount,
814 ir_module: None,
815 slots: None,
816 });
817
818 assert!(policy(&page).contains(&format!("'nonce-{}'", page.nonce)));
820 assert!(page
821 .html
822 .contains(&format!("<script nonce=\"{}\">", page.nonce)));
823 assert!(page
824 .html
825 .contains(&format!("<style nonce=\"{}\">", page.nonce)));
826 }
827
828 #[test]
829 fn page_response_headers_are_no_store() {
830 let manifest = empty_manifest();
832 let page = render_parts(&PageConfig {
833 title: "Header Test",
834 route_pattern: "/test",
835 manifest: &manifest,
836 config_json: None,
837 body_class: None,
838 personality_css: None,
839 body_prefix: None,
840 render_mode: RenderMode::Phase1ClientMount,
841 ir_module: None,
842 slots: None,
843 });
844
845 let headers = page.headers;
846 let get = |name: &str| {
847 headers
848 .iter()
849 .find(|(n, _)| n.as_str() == name)
850 .map(|(_, v)| v.to_str().unwrap().to_string())
851 };
852 assert_eq!(get("cache-control").as_deref(), Some("no-store"));
853 assert_eq!(get("x-content-type-options").as_deref(), Some("nosniff"));
854 assert!(get("content-security-policy").is_some());
855 assert!(get("referrer-policy").is_some());
856 }
857
858 #[test]
861 fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
862 let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
866 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(
870 &["div", "style", "css"],
871 &[(0, 2, 0x01, 0x00, &[])],
872 &opcodes,
873 &[],
874 );
875 let ir = IrModule::parse(&data).unwrap();
876 let mut slots = SlotData::new(1);
877 slots.set(0, SlotValue::Text("color:red".into()));
878
879 let manifest = empty_manifest();
880 let page = render_parts(&PageConfig {
881 title: "Style Attr",
882 route_pattern: "/test",
883 manifest: &manifest,
884 config_json: None,
885 body_class: None,
886 personality_css: None,
887 body_prefix: None,
888 render_mode: RenderMode::Phase2SsrReconcile,
889 ir_module: Some(&ir),
890 slots: Some(&slots),
891 });
892
893 assert!(
894 page.html.contains("style=\"color:red\""),
895 "walker must emit the style attribute: {}",
896 page.html
897 );
898 assert!(
899 policy(&page).contains("style-src-attr 'unsafe-inline'"),
900 "CSP must permit the attribute style the walker just emitted: {}",
901 policy(&page)
902 );
903 }
904}