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/// If `nonce` is `Some`, a `nonce="…"` attribute is inserted between `type`
20/// and `>` so strict CSP `script-src 'nonce-…'` policies allow the inline
21/// script. CSS bytes are percent-encoded so they survive the `data:` URI
22/// parser; the importmap JSON is produced via `serde_json` so the specifier
23/// and URI value are correctly JSON-escaped.
24///
25/// Requires browser support for Multiple Import Maps (Chrome 133+); the
26/// browser merges each emitted importmap into the document-level resolution
27/// table.
28#[must_use]
29pub fn build_importmap_tag(specifier: &str, css: &str, nonce: Option<&str>) -> String {
30    let data_uri = build_data_uri(css);
31    let body = build_importmap_json(specifier, data_uri);
32
33    // `<script type="importmap"></script>` is 33 chars; `nonce=""` adds 8 +
34    // the value. A few extra bytes avoid a reallocation when the body is
35    // small.
36    let cap = 40 + body.len() + nonce.map_or(0, |n| n.len() + 9);
37    let mut out = String::with_capacity(cap);
38    out.push_str("<script type=\"importmap\"");
39    if let Some(n) = nonce {
40        out.push_str(" nonce=\"");
41        out.push_str(n);
42        out.push('"');
43    }
44    out.push('>');
45    out.push_str(&body);
46    out.push_str("</script>");
47    out
48}
49
50fn build_data_uri(css: &str) -> String {
51    let mut out = String::with_capacity("data:text/css,".len() + css.len());
52    out.push_str("data:text/css,");
53    percent_encode_css_into(css, &mut out);
54    out
55}
56
57fn build_importmap_json(specifier: &str, data_uri: String) -> String {
58    let mut imports = serde_json::Map::with_capacity(1);
59    imports.insert(specifier.to_owned(), Value::String(data_uri));
60    let mut root = serde_json::Map::with_capacity(1);
61    root.insert("imports".into(), Value::Object(imports));
62    Value::Object(root).to_string()
63}
64
65// Percent-encode bytes that would mis-parse in a `data:` URI or break out
66// of the surrounding `<script type="importmap">` raw-text element:
67// `%` (escape), `#` (fragment delimiter), `"`, `<` / `>` (HTML script-data
68// terminator + attribute parser), whitespace, and non-ASCII / control bytes.
69fn percent_encode_css_into(css: &str, out: &mut String) {
70    for b in css.bytes() {
71        let needs_encoding = matches!(
72            b,
73            b'%' | b'#' | b'"' | b'<' | b'>' | b' ' | b'\t' | b'\n' | b'\r'
74        ) || !(0x20..0x80).contains(&b);
75        if needs_encoding {
76            let _ = write!(out, "%{:02X}", b);
77        } else {
78            out.push(char::from(b));
79        }
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn build_importmap_tag_basic() {
89        let tag = build_importmap_tag("my-comp", "span{color:blue;}", None);
90        assert_eq!(
91            tag,
92            r#"<script type="importmap">{"imports":{"my-comp":"data:text/css,span{color:blue;}"}}</script>"#
93        );
94    }
95
96    #[test]
97    fn build_importmap_tag_with_nonce() {
98        let tag = build_importmap_tag("dash-page", "h1{font-size:2rem}", Some("test-nonce-123"));
99        assert_eq!(
100            tag,
101            r#"<script type="importmap" nonce="test-nonce-123">{"imports":{"dash-page":"data:text/css,h1{font-size:2rem}"}}</script>"#
102        );
103    }
104
105    #[test]
106    fn percent_encoder_escapes_url_unsafe_bytes() {
107        let mut out = String::new();
108        // %, #, ", <, >, whitespace must all be percent-escaped. Printable
109        // ASCII outside that set (including `{`, `}`, `\`) is preserved
110        // verbatim; JSON-level escaping of `\` and `"` is handled by
111        // serde_json when the URI is embedded inside the importmap object.
112        percent_encode_css_into(".a{content:\"\\E000 #x %y\";}", &mut out);
113        assert_eq!(out, r#".a{content:%22\E000%20%23x%20%25y%22;}"#);
114    }
115
116    #[test]
117    fn percent_encoder_escapes_non_ascii_bytes() {
118        let mut out = String::new();
119        percent_encode_css_into(".a::before{content:\"★\"}", &mut out);
120        // ★ is U+2605, UTF-8 bytes E2 98 85.
121        assert_eq!(out, ".a::before{content:%22%E2%98%85%22}");
122    }
123
124    #[test]
125    fn empty_css_produces_empty_data_uri() {
126        let tag = build_importmap_tag("empty", "", None);
127        assert!(tag.contains(r#""empty":"data:text/css,""#));
128    }
129
130    #[test]
131    fn json_layer_escapes_backslash_in_css() {
132        // CSS escapes like `\E000` survive the percent encoder (the bytes
133        // are printable ASCII) but must still be JSON-escaped so the
134        // resulting importmap parses. `serde_json` produces `\\E000`.
135        let tag = build_importmap_tag("ic", "a::before{content:\"\\E000\"}", None);
136        assert!(
137            tag.contains(r#""data:text/css,a::before{content:%22\\E000%22}""#),
138            "backslash inside the data URI must be JSON-escaped (got: {tag})"
139        );
140    }
141
142    #[test]
143    fn css_with_script_close_tag_cannot_break_out_of_importmap_script() {
144        // Regression guard: `<` and `>` must be percent-encoded so CSS
145        // content containing `</script>` (or any tag-like sequence) cannot
146        // terminate the surrounding `<script type="importmap">` element.
147        // The HTML parser tokenizes script bodies in raw-text mode and
148        // will treat any literal `</script>` as the end tag regardless of
149        // JSON quoting.
150        let malicious = r#".a::before{content:"</script><script>alert(1)</script>";}"#;
151        let tag = build_importmap_tag("evil", malicious, None);
152
153        // Exactly one `</script>` (the real closing tag) and one `<script`
154        // (the real opening tag) may appear; the encoded payload must not
155        // contribute any extra tag-like sequences.
156        assert_eq!(
157            tag.matches("</script>").count(),
158            1,
159            "only the legitimate closing tag may appear: {tag}"
160        );
161        assert_eq!(
162            tag.matches("<script").count(),
163            1,
164            "only the legitimate opening tag may appear: {tag}"
165        );
166
167        // The body (between the opening `>` and the real closing tag)
168        // must not contain any raw `<` or `>`.
169        let body_start = tag.find('>').expect("opening tag must terminate") + 1;
170        let body_end = tag.rfind("</script>").expect("closing tag must exist");
171        let body = &tag[body_start..body_end];
172        assert!(
173            !body.contains('<') && !body.contains('>'),
174            "no raw `<` or `>` may appear inside the importmap body: {body}"
175        );
176    }
177}