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