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 build_attr = format!(
271 " data-forma-build=\"{}\"",
272 escape_html(&config.manifest.build)
273 );
274
275 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 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 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, &[])); opcodes.extend_from_slice(&encode_text(1)); opcodes.extend_from_slice(&encode_close_tag(0)); 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 assert!(page
417 .html
418 .contains("<div id=\"app\" data-forma-build=\"test\"></div>"));
419 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 #[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"b<c>&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 let mut opcodes = Vec::new();
539 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
542 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(
547 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &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 assert!(page
574 .html
575 .contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
576 }
577
578 #[test]
581 fn escape_html_basic_entities() {
582 assert_eq!(escape_html("<script>"), "<script>");
583 assert_eq!(escape_html("\"quotes\""), ""quotes"");
584 assert_eq!(escape_html("a&b"), "a&b");
585 assert_eq!(escape_html("clean"), "clean");
586 }
587
588 #[test]
589 fn asset_filename_xss_is_escaped() {
590 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 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("<script>"),
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 assert!(
662 page.html.contains("""),
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 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 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 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 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 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 let parsed: serde_json::Value = serde_json::from_str(json.trim_end_matches(';')).unwrap();
785 assert_eq!(parsed, value);
786 }
787
788 #[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 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 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 #[test]
849 fn style_attributes_rendered_by_the_walker_are_permitted_by_the_csp() {
850 let mut opcodes = forma_ir::parser::test_helpers::encode_open_tag(0, &[]);
854 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(
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}