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
62pub(crate) fn write_style_text(writer: &mut dyn ResponseWriter, css: &str) -> Result<()> {
68 let bytes = css.as_bytes();
69 let mut chunk_start = 0usize;
70 let mut index = 0usize;
71 while index + 7 <= bytes.len() {
72 if bytes[index] == b'<'
73 && bytes[index + 1] == b'/'
74 && bytes[index + 2].eq_ignore_ascii_case(&b's')
75 && bytes[index + 3].eq_ignore_ascii_case(&b't')
76 && bytes[index + 4].eq_ignore_ascii_case(&b'y')
77 && bytes[index + 5].eq_ignore_ascii_case(&b'l')
78 && bytes[index + 6].eq_ignore_ascii_case(&b'e')
79 {
80 writer.write(&css[chunk_start..index + 1])?;
81 writer.write("\\/")?;
82 chunk_start = index + 2;
83 index += 7;
84 } else {
85 index += 1;
86 }
87 }
88 writer.write(&css[chunk_start..])
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[derive(Default)]
96 struct StyleWriter {
97 output: String,
98 writes: usize,
99 }
100
101 impl ResponseWriter for StyleWriter {
102 fn write(&mut self, content: &str) -> Result<()> {
103 self.output.push_str(content);
104 self.writes += 1;
105 Ok(())
106 }
107
108 fn end(&mut self) -> Result<()> {
109 Ok(())
110 }
111 }
112
113 #[test]
114 fn no_escaping_returns_borrowed() {
115 let input = "Hello World 123";
116 let result = encode_safe(input);
117 assert!(matches!(result, Cow::Borrowed(_)));
118 assert_eq!(result, "Hello World 123");
119 }
120
121 #[test]
122 fn empty_string_returns_borrowed() {
123 let result = encode_safe("");
124 assert!(matches!(result, Cow::Borrowed(_)));
125 assert_eq!(result, "");
126 }
127
128 #[test]
129 fn escapes_ampersand() {
130 assert_eq!(encode_safe("a&b"), "a&b");
131 }
132
133 #[test]
134 fn escapes_less_than() {
135 assert_eq!(encode_safe("<script>"), "<script>");
136 }
137
138 #[test]
139 fn escapes_greater_than() {
140 assert_eq!(encode_safe("a>b"), "a>b");
141 }
142
143 #[test]
144 fn escapes_double_quote() {
145 assert_eq!(encode_safe(r#"a"b"#), "a"b");
146 }
147
148 #[test]
149 fn escapes_single_quote() {
150 assert_eq!(encode_safe("a'b"), "a'b");
151 }
152
153 #[test]
154 fn escapes_forward_slash() {
155 assert_eq!(encode_safe("a/b"), "a/b");
156 }
157
158 #[test]
159 fn escapes_all_special_chars() {
160 assert_eq!(encode_safe(r#"&<>"'/"#), "&<>"'/");
161 }
162
163 #[test]
164 fn preserves_unicode() {
165 assert_eq!(encode_safe("こんにちは"), "こんにちは");
166 assert!(matches!(encode_safe("こんにちは"), Cow::Borrowed(_)));
167 }
168
169 #[test]
170 fn mixed_unicode_and_special() {
171 assert_eq!(encode_safe("日本語&テスト"), "日本語&テスト");
172 }
173
174 #[test]
175 fn escapes_at_start() {
176 assert_eq!(encode_safe("&start"), "&start");
177 }
178
179 #[test]
180 fn escapes_at_end() {
181 assert_eq!(encode_safe("end&"), "end&");
182 }
183
184 #[test]
185 fn multiple_consecutive_escapes() {
186 assert_eq!(encode_safe("&&"), "&&");
187 }
188
189 #[test]
190 fn realistic_nonce() {
191 let nonce = "YWJjZGVmZ2hpamtsbW5vcA==";
193 let result = encode_safe(nonce);
194 assert!(matches!(result, Cow::Borrowed(_)));
195 }
196
197 #[test]
198 fn realistic_attribute_value() {
199 assert_eq!(
200 encode_safe("John O'Brien & Associates"),
201 "John O'Brien & Associates"
202 );
203 }
204
205 #[test]
206 fn realistic_xss_attempt() {
207 assert_eq!(
208 encode_safe("<script>alert('xss')</script>"),
209 "<script>alert('xss')</script>"
210 );
211 }
212
213 #[test]
214 fn escapes_all_known_special_chars() {
215 let test_cases = [
217 ("", ""),
218 ("hello", "hello"),
219 ("&", "&"),
220 ("<", "<"),
221 (">", ">"),
222 ("\"", """),
223 ("'", "'"),
224 ("/", "/"),
225 ("a&b<c>d\"e'f/g", "a&b<c>d"e'f/g"),
226 ];
227 for (input, expected) in test_cases {
228 assert_eq!(encode_safe(input), expected, "Failed for input: {input:?}");
229 }
230 }
231
232 #[test]
233 fn style_text_preserves_normal_bytes_in_one_write() {
234 let mut writer = StyleWriter::default();
235 write_style_text(&mut writer, ".card{content:'normal / style'}").unwrap();
236 assert_eq!(writer.output, ".card{content:'normal / style'}");
237 assert_eq!(writer.writes, 1);
238 }
239
240 #[test]
241 fn style_text_escapes_mixed_case_end_tags() {
242 let mut writer = StyleWriter::default();
243 write_style_text(
244 &mut writer,
245 ".a{content:'</style>'}.b{content:'</StYlE attr>'}",
246 )
247 .unwrap();
248 assert_eq!(
249 writer.output,
250 ".a{content:'<\\/style>'}.b{content:'<\\/StYlE attr>'}"
251 );
252 }
253}