Skip to main content

webui_handler/
css_module.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Shared helpers for emitting CSS module definitions as `<script type="importmap">`
5//! tags with `data:text/css,…` URIs.
6//!
7//! Both the SSR inline emission path (`lib.rs::emit_css_module_importmap`) and
8//! the SPA partial-navigation path (`route_handler.rs::collect_component_assets`)
9//! use this helper to produce a single canonical wire shape, so the client only
10//! needs to understand one format.
11
12use std::fmt::Write as _;
13
14use serde_json::Value;
15
16/// Build a complete `<script type="importmap">` tag string that registers a
17/// single CSS module under `specifier` via a `data:text/css,…` URI.
18///
19/// The tag carries `data-webui-resource` so Light DOM hydration excludes the
20/// compiler-emitted script from template node ordinals. If `nonce` is `Some`,
21/// a `nonce="…"` attribute is inserted so strict CSP `script-src 'nonce-…'`
22/// policies allow the inline script. CSS bytes are percent-encoded so they
23/// survive the `data:` URI parser; the importmap JSON is produced via
24/// `serde_json` so the specifier and URI value are correctly JSON-escaped.
25///
26/// Requires browser support for Multiple Import Maps (Chrome 133+); the
27/// browser merges each emitted importmap into the document-level resolution
28/// table.
29#[must_use]
30pub fn build_importmap_tag(specifier: &str, css: &str, nonce: Option<&str>) -> String {
31    let data_uri = build_data_uri(css);
32    let body = build_importmap_json(specifier, data_uri);
33
34    let escaped_specifier = crate::html_encode::encode_safe(specifier);
35    // `<script type="importmap" data-webui-resource=""></script>` is 56 chars;
36    // `nonce=""` adds 8 + the value. A few extra bytes avoid a reallocation
37    // when the body is small.
38    let cap = 64 + body.len() + escaped_specifier.len() + nonce.map_or(0, |n| n.len() + 9);
39    let mut out = String::with_capacity(cap);
40    out.push_str("<script type=\"importmap\"");
41    if let Some(n) = nonce {
42        out.push_str(" nonce=\"");
43        out.push_str(n);
44        out.push('"');
45    }
46    out.push_str(" data-webui-resource=\"");
47    out.push_str(&escaped_specifier);
48    out.push('"');
49    out.push('>');
50    out.push_str(&body);
51    out.push_str("</script>");
52    out
53}
54
55/// Build one import map containing every supplied CSS module.
56///
57/// Streaming checkpoints use one element so the payload script remains within
58/// the browser coordinator's bounded sentinel lookback even when several
59/// reachable-but-not-yet-rendered components first become available together.
60pub(crate) fn build_importmap_tag_batch(
61    modules: &[(&str, &str)],
62    nonce: Option<&str>,
63) -> Option<String> {
64    if modules.is_empty() {
65        return None;
66    }
67    let mut imports = serde_json::Map::with_capacity(modules.len());
68    for &(specifier, css) in modules {
69        imports.insert(specifier.to_owned(), Value::String(build_data_uri(css)));
70    }
71    let mut root = serde_json::Map::with_capacity(1);
72    root.insert("imports".into(), Value::Object(imports));
73    let body = Value::Object(root).to_string();
74    let cap = 40 + body.len() + nonce.map_or(0, |value| value.len() + 9);
75    let mut out = String::with_capacity(cap);
76    out.push_str("<script type=\"importmap\"");
77    if let Some(value) = nonce {
78        out.push_str(" nonce=\"");
79        out.push_str(value);
80        out.push('"');
81    }
82    out.push('>');
83    out.push_str(&body);
84    out.push_str("</script>");
85    Some(out)
86}
87
88fn build_data_uri(css: &str) -> String {
89    let mut out = String::with_capacity("data:text/css,".len() + css.len());
90    out.push_str("data:text/css,");
91    percent_encode_css_into(css, &mut out);
92    out
93}
94
95fn build_importmap_json(specifier: &str, data_uri: String) -> String {
96    let mut imports = serde_json::Map::with_capacity(1);
97    imports.insert(specifier.to_owned(), Value::String(data_uri));
98    let mut root = serde_json::Map::with_capacity(1);
99    root.insert("imports".into(), Value::Object(imports));
100    Value::Object(root).to_string()
101}
102
103// Percent-encode bytes that would mis-parse in a `data:` URI or break out
104// of the surrounding `<script type="importmap">` raw-text element:
105// `%` (escape), `#` (fragment delimiter), `"`, `<` / `>` (HTML script-data
106// terminator + attribute parser), whitespace, and non-ASCII / control bytes.
107fn percent_encode_css_into(css: &str, out: &mut String) {
108    for b in css.bytes() {
109        let needs_encoding = matches!(
110            b,
111            b'%' | b'#' | b'"' | b'<' | b'>' | b' ' | b'\t' | b'\n' | b'\r'
112        ) || !(0x20..0x80).contains(&b);
113        if needs_encoding {
114            let _ = write!(out, "%{:02X}", b);
115        } else {
116            out.push(char::from(b));
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn build_importmap_tag_basic() {
127        let tag = build_importmap_tag("my-comp", "span{color:blue;}", None);
128        assert_eq!(
129            tag,
130            r#"<script type="importmap" data-webui-resource="my-comp">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>"#
131        );
132    }
133
134    #[test]
135    fn build_importmap_tag_with_nonce() {
136        let tag = build_importmap_tag("dash-page", "h1{font-size:2rem}", Some("test-nonce-123"));
137        assert_eq!(
138            tag,
139            r#"<script type="importmap" nonce="test-nonce-123" data-webui-resource="dash-page">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
140        );
141    }
142
143    #[test]
144    fn percent_encoder_escapes_url_unsafe_bytes() {
145        let mut out = String::new();
146        // %, #, ", <, >, whitespace must all be percent-escaped. Printable
147        // ASCII outside that set (including `{`, `}`, `\`) is preserved
148        // verbatim; JSON-level escaping of `\` and `"` is handled by
149        // serde_json when the URI is embedded inside the importmap object.
150        percent_encode_css_into(".a{content:\"\\E000 #x %y\";}", &mut out);
151        assert_eq!(out, r#".a{content:%22\E000%20%23x%20%25y%22;}"#);
152    }
153
154    #[test]
155    fn percent_encoder_escapes_non_ascii_bytes() {
156        let mut out = String::new();
157        percent_encode_css_into(".a::before{content:\"★\"}", &mut out);
158        // ★ is U+2605, UTF-8 bytes E2 98 85.
159        assert_eq!(out, ".a::before{content:%22%E2%98%85%22}");
160    }
161
162    #[test]
163    fn empty_css_produces_empty_data_uri() {
164        let tag = build_importmap_tag("empty", "", None);
165        assert!(tag.contains(r#""empty":"data:text/css,""#));
166    }
167
168    #[test]
169    fn batch_importmap_uses_one_script_for_multiple_modules() {
170        let modules = [("a-card", "a{}"), ("b-card", "b{}")];
171        let tag = build_importmap_tag_batch(&modules, Some("nonce")).unwrap();
172        assert_eq!(tag.matches("<script").count(), 1);
173        assert!(tag.contains(r#""a-card":"data:text/css,a{}""#));
174        assert!(tag.contains(r#""b-card":"data:text/css,b{}""#));
175        assert!(tag.contains(r#"nonce="nonce""#));
176    }
177
178    #[test]
179    fn json_layer_escapes_backslash_in_css() {
180        // CSS escapes like `\E000` survive the percent encoder (the bytes
181        // are printable ASCII) but must still be JSON-escaped so the
182        // resulting importmap parses. `serde_json` produces `\\E000`.
183        let tag = build_importmap_tag("ic", "a::before{content:\"\\E000\"}", None);
184        assert!(
185            tag.contains(r#""data:text/css,a::before{content:%22\\E000%22}""#),
186            "backslash inside the data URI must be JSON-escaped (got: {tag})"
187        );
188    }
189
190    #[test]
191    fn css_with_script_close_tag_cannot_break_out_of_importmap_script() {
192        // Regression guard: `<` and `>` must be percent-encoded so CSS
193        // content containing `</script>` (or any tag-like sequence) cannot
194        // terminate the surrounding `<script type="importmap">` element.
195        // The HTML parser tokenizes script bodies in raw-text mode and
196        // will treat any literal `</script>` as the end tag regardless of
197        // JSON quoting.
198        let malicious = r#".a::before{content:"</script><script>alert(1)</script>";}"#;
199        let tag = build_importmap_tag("evil", malicious, None);
200
201        // Exactly one `</script>` (the real closing tag) and one `<script`
202        // (the real opening tag) may appear; the encoded payload must not
203        // contribute any extra tag-like sequences.
204        assert_eq!(
205            tag.matches("</script>").count(),
206            1,
207            "only the legitimate closing tag may appear: {tag}"
208        );
209        assert_eq!(
210            tag.matches("<script").count(),
211            1,
212            "only the legitimate opening tag may appear: {tag}"
213        );
214
215        // The body (between the opening `>` and the real closing tag)
216        // must not contain any raw `<` or `>`.
217        let body_start = tag.find('>').expect("opening tag must terminate") + 1;
218        let body_end = tag.rfind("</script>").expect("closing tag must exist");
219        let body = &tag[body_start..body_end];
220        assert!(
221            !body.contains('<') && !body.contains('>'),
222            "no raw `<` or `>` may appear inside the importmap body: {body}"
223        );
224    }
225}