webui_handler/
css_module.rs1use std::fmt::Write as _;
13
14use serde_json::Value;
15
16#[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 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
55pub(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
103fn 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 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 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 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 let malicious = r#".a::before{content:"</script><script>alert(1)</script>";}"#;
199 let tag = build_importmap_tag("evil", malicious, None);
200
201 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 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}