webui_handler/
html_encode.rs1use std::borrow::Cow;
10
11use crate::{ResponseWriter, Result};
12
13pub fn encode_safe(input: &str) -> Cow<'_, str> {
25 let bytes = input.as_bytes();
26
27 let first = bytes
29 .iter()
30 .position(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'' | b'/'));
31
32 let Some(pos) = first else {
33 return Cow::Borrowed(input);
34 };
35
36 let mut out = String::with_capacity(input.len() + 6);
38 out.push_str(&input[..pos]);
39
40 let mut start = pos;
41 for (i, &b) in bytes[pos..].iter().enumerate() {
42 let replacement = match b {
43 b'&' => "&",
44 b'<' => "<",
45 b'>' => ">",
46 b'"' => """,
47 b'\'' => "'",
48 b'/' => "/",
49 _ => continue,
50 };
51 out.push_str(&input[start..pos + i]);
53 out.push_str(replacement);
54 start = pos + i + 1;
55 }
56 out.push_str(&input[start..]);
58
59 Cow::Owned(out)
60}
61
62#[inline]
63fn is_style_end_tag(bytes: &[u8], index: usize) -> bool {
64 bytes[index] == b'<'
65 && bytes[index + 1] == b'/'
66 && bytes[index + 2].eq_ignore_ascii_case(&b's')
67 && bytes[index + 3].eq_ignore_ascii_case(&b't')
68 && bytes[index + 4].eq_ignore_ascii_case(&b'y')
69 && bytes[index + 5].eq_ignore_ascii_case(&b'l')
70 && bytes[index + 6].eq_ignore_ascii_case(&b'e')
71}
72
73pub(crate) fn style_text_needs_escape(css: &str) -> bool {
75 let bytes = css.as_bytes();
76 let mut index = 0usize;
77 while index + 7 <= bytes.len() {
78 if is_style_end_tag(bytes, index) {
79 return true;
80 }
81 index += 1;
82 }
83 false
84}
85
86pub(crate) fn write_style_text(writer: &mut dyn ResponseWriter, css: &str) -> Result<()> {
92 let bytes = css.as_bytes();
93 let mut chunk_start = 0usize;
94 let mut index = 0usize;
95 while index + 7 <= bytes.len() {
96 if is_style_end_tag(bytes, index) {
97 writer.write(&css[chunk_start..index + 1])?;
98 writer.write("\\/")?;
99 chunk_start = index + 2;
100 index += 7;
101 } else {
102 index += 1;
103 }
104 }
105 writer.write(&css[chunk_start..])
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[derive(Default)]
113 struct StyleWriter {
114 output: String,
115 writes: usize,
116 }
117
118 impl ResponseWriter for StyleWriter {
119 fn write(&mut self, content: &str) -> Result<()> {
120 self.output.push_str(content);
121 self.writes += 1;
122 Ok(())
123 }
124
125 fn end(&mut self) -> Result<()> {
126 Ok(())
127 }
128 }
129
130 #[test]
131 fn no_escaping_returns_borrowed() {
132 let input = "Hello World 123";
133 let result = encode_safe(input);
134 assert!(matches!(result, Cow::Borrowed(_)));
135 assert_eq!(result, "Hello World 123");
136 }
137
138 #[test]
139 fn empty_string_returns_borrowed() {
140 let result = encode_safe("");
141 assert!(matches!(result, Cow::Borrowed(_)));
142 assert_eq!(result, "");
143 }
144
145 #[test]
146 fn escapes_ampersand() {
147 assert_eq!(encode_safe("a&b"), "a&b");
148 }
149
150 #[test]
151 fn escapes_less_than() {
152 assert_eq!(encode_safe("<script>"), "<script>");
153 }
154
155 #[test]
156 fn escapes_greater_than() {
157 assert_eq!(encode_safe("a>b"), "a>b");
158 }
159
160 #[test]
161 fn escapes_double_quote() {
162 assert_eq!(encode_safe(r#"a"b"#), "a"b");
163 }
164
165 #[test]
166 fn escapes_single_quote() {
167 assert_eq!(encode_safe("a'b"), "a'b");
168 }
169
170 #[test]
171 fn escapes_forward_slash() {
172 assert_eq!(encode_safe("a/b"), "a/b");
173 }
174
175 #[test]
176 fn escapes_all_special_chars() {
177 assert_eq!(encode_safe(r#"&<>"'/"#), "&<>"'/");
178 }
179
180 #[test]
181 fn preserves_unicode() {
182 assert_eq!(encode_safe("こんにちは"), "こんにちは");
183 assert!(matches!(encode_safe("こんにちは"), Cow::Borrowed(_)));
184 }
185
186 #[test]
187 fn mixed_unicode_and_special() {
188 assert_eq!(encode_safe("日本語&テスト"), "日本語&テスト");
189 }
190
191 #[test]
192 fn escapes_at_start() {
193 assert_eq!(encode_safe("&start"), "&start");
194 }
195
196 #[test]
197 fn escapes_at_end() {
198 assert_eq!(encode_safe("end&"), "end&");
199 }
200
201 #[test]
202 fn multiple_consecutive_escapes() {
203 assert_eq!(encode_safe("&&"), "&&");
204 }
205
206 #[test]
207 fn realistic_nonce() {
208 let nonce = "YWJjZGVmZ2hpamtsbW5vcA==";
210 let result = encode_safe(nonce);
211 assert!(matches!(result, Cow::Borrowed(_)));
212 }
213
214 #[test]
215 fn realistic_attribute_value() {
216 assert_eq!(
217 encode_safe("John O'Brien & Associates"),
218 "John O'Brien & Associates"
219 );
220 }
221
222 #[test]
223 fn realistic_xss_attempt() {
224 assert_eq!(
225 encode_safe("<script>alert('xss')</script>"),
226 "<script>alert('xss')</script>"
227 );
228 }
229
230 #[test]
231 fn escapes_all_known_special_chars() {
232 let test_cases = [
234 ("", ""),
235 ("hello", "hello"),
236 ("&", "&"),
237 ("<", "<"),
238 (">", ">"),
239 ("\"", """),
240 ("'", "'"),
241 ("/", "/"),
242 ("a&b<c>d\"e'f/g", "a&b<c>d"e'f/g"),
243 ];
244 for (input, expected) in test_cases {
245 assert_eq!(encode_safe(input), expected, "Failed for input: {input:?}");
246 }
247 }
248
249 #[test]
250 fn style_text_preserves_normal_bytes_in_one_write() {
251 let mut writer = StyleWriter::default();
252 write_style_text(&mut writer, ".card{content:'normal / style'}").unwrap();
253 assert_eq!(writer.output, ".card{content:'normal / style'}");
254 assert_eq!(writer.writes, 1);
255 }
256
257 #[test]
258 fn style_text_escapes_mixed_case_end_tags() {
259 let mut writer = StyleWriter::default();
260 write_style_text(
261 &mut writer,
262 ".a{content:'</style>'}.b{content:'</StYlE attr>'}",
263 )
264 .unwrap();
265 assert_eq!(
266 writer.output,
267 ".a{content:'<\\/style>'}.b{content:'<\\/StYlE attr>'}"
268 );
269 }
270
271 #[test]
272 fn style_text_escape_detection_matches_writer() {
273 let cases = [
274 ("", false),
275 ("</styl", false),
276 ("< /style", false),
277 ("<\\/style", false),
278 ("</style", true),
279 ("prefix</STYLE>suffix", true),
280 ("a</StYlE attr>b", true),
281 ];
282
283 for (css, expected) in cases {
284 assert_eq!(
285 style_text_needs_escape(css),
286 expected,
287 "unexpected detection for {css:?}"
288 );
289
290 let mut writer = StyleWriter::default();
291 write_style_text(&mut writer, css).unwrap();
292 assert_eq!(
293 writer.output != css,
294 expected,
295 "writer and detector diverged for {css:?}"
296 );
297 }
298 }
299}