1use 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.iter().position(|&byte| needs_escape(byte));
29
30 let Some(pos) = first else {
31 return Cow::Borrowed(input);
32 };
33
34 let extra: usize = bytes[pos..].iter().map(|&b| escaped_extra_len(b)).sum();
37 let mut out = String::with_capacity(input.len() + extra);
38 out.push_str(&input[..pos]);
39
40 let mut start = pos;
41 for (i, &b) in bytes[pos..].iter().enumerate() {
42 let Some(replacement) = escaped_replacement(b) else {
43 continue;
44 };
45 out.push_str(&input[start..pos + i]);
47 out.push_str(replacement);
48 start = pos + i + 1;
49 }
50 out.push_str(&input[start..]);
52
53 Cow::Owned(out)
54}
55
56#[inline]
57const fn needs_escape(byte: u8) -> bool {
58 matches!(byte, b'&' | b'<' | b'>' | b'"' | b'\'' | b'/')
59}
60
61#[inline]
62const fn escaped_replacement(byte: u8) -> Option<&'static str> {
63 match byte {
64 b'&' => Some("&"),
65 b'<' => Some("<"),
66 b'>' => Some(">"),
67 b'"' => Some("""),
68 b'\'' => Some("'"),
69 b'/' => Some("/"),
70 _ => None,
71 }
72}
73
74#[inline]
75fn escaped_chunks(input: &str, mut append: impl FnMut(&str)) {
76 let Some(first) = input.as_bytes().iter().position(|&byte| needs_escape(byte)) else {
77 append(input);
78 return;
79 };
80 if first > 0 {
81 append(&input[..first]);
82 }
83 let mut start = first;
84 for (offset, &byte) in input.as_bytes()[first..].iter().enumerate() {
85 let index = first + offset;
86 if let Some(replacement) = escaped_replacement(byte) {
87 if start < index {
88 append(&input[start..index]);
89 }
90 append(replacement);
91 start = index + 1;
92 }
93 }
94 if start < input.len() {
95 append(&input[start..]);
96 }
97}
98
99#[doc(hidden)]
101#[inline]
102pub fn append_escaped_to_string(output: &mut String, input: &str) {
103 escaped_chunks(input, |chunk| output.push_str(chunk));
104}
105
106#[doc(hidden)]
108#[inline]
109pub fn append_escaped_to_bytes(output: &mut Vec<u8>, input: &str) {
110 escaped_chunks(input, |chunk| output.extend_from_slice(chunk.as_bytes()));
111}
112
113#[inline]
115const fn escaped_extra_len(b: u8) -> usize {
116 match b {
117 b'&' => 4,
118 b'<' | b'>' => 3,
119 b'"' | b'\'' | b'/' => 5,
120 _ => 0,
121 }
122}
123
124#[inline]
125fn is_style_end_tag(bytes: &[u8], index: usize) -> bool {
126 bytes[index] == b'<'
127 && bytes[index + 1] == b'/'
128 && bytes[index + 2].eq_ignore_ascii_case(&b's')
129 && bytes[index + 3].eq_ignore_ascii_case(&b't')
130 && bytes[index + 4].eq_ignore_ascii_case(&b'y')
131 && bytes[index + 5].eq_ignore_ascii_case(&b'l')
132 && bytes[index + 6].eq_ignore_ascii_case(&b'e')
133}
134
135pub(crate) fn style_text_needs_escape(css: &str) -> bool {
137 let bytes = css.as_bytes();
138 let mut index = 0usize;
139 while index + 7 <= bytes.len() {
140 if is_style_end_tag(bytes, index) {
141 return true;
142 }
143 index += 1;
144 }
145 false
146}
147
148pub(crate) fn write_style_text(writer: &mut dyn ResponseWriter, css: &str) -> Result<()> {
154 let bytes = css.as_bytes();
155 let mut chunk_start = 0usize;
156 let mut index = 0usize;
157 while index + 7 <= bytes.len() {
158 if is_style_end_tag(bytes, index) {
159 writer.write(&css[chunk_start..index + 1])?;
160 writer.write("\\/")?;
161 chunk_start = index + 2;
162 index += 7;
163 } else {
164 index += 1;
165 }
166 }
167 writer.write(&css[chunk_start..])
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn escaped_append_preserves_existing_content_and_encoding() {
176 let ascii: String = (0..=127).map(char::from).collect();
177 for input in ["", "plain text", "&<>\"'/", "&", "é水🙂/", &ascii] {
178 let expected = format!("prefix{}", encode_safe(input));
179 let mut text = String::from("prefix");
180 let mut bytes = b"prefix".to_vec();
181 append_escaped_to_string(&mut text, input);
182 append_escaped_to_bytes(&mut bytes, input);
183 assert_eq!(text, expected);
184 assert_eq!(bytes, expected.as_bytes());
185 }
186 }
187
188 #[derive(Default)]
189 struct StyleWriter {
190 output: String,
191 writes: usize,
192 }
193
194 impl ResponseWriter for StyleWriter {
195 fn write(&mut self, content: &str) -> Result<()> {
196 self.output.push_str(content);
197 self.writes += 1;
198 Ok(())
199 }
200
201 fn end(&mut self) -> Result<()> {
202 Ok(())
203 }
204 }
205
206 #[test]
207 fn no_escaping_returns_borrowed() {
208 let input = "Hello World 123";
209 let result = encode_safe(input);
210 assert!(matches!(result, Cow::Borrowed(_)));
211 assert_eq!(result, "Hello World 123");
212 }
213
214 #[test]
215 fn empty_string_returns_borrowed() {
216 let result = encode_safe("");
217 assert!(matches!(result, Cow::Borrowed(_)));
218 assert_eq!(result, "");
219 }
220
221 #[test]
222 fn escapes_ampersand() {
223 assert_eq!(encode_safe("a&b"), "a&b");
224 }
225
226 #[test]
227 fn escapes_less_than() {
228 assert_eq!(encode_safe("<script>"), "<script>");
229 }
230
231 #[test]
232 fn escapes_greater_than() {
233 assert_eq!(encode_safe("a>b"), "a>b");
234 }
235
236 #[test]
237 fn escapes_double_quote() {
238 assert_eq!(encode_safe(r#"a"b"#), "a"b");
239 }
240
241 #[test]
242 fn escapes_single_quote() {
243 assert_eq!(encode_safe("a'b"), "a'b");
244 }
245
246 #[test]
247 fn escapes_forward_slash() {
248 assert_eq!(encode_safe("a/b"), "a/b");
249 }
250
251 #[test]
252 fn escapes_all_special_chars() {
253 assert_eq!(encode_safe(r#"&<>"'/"#), "&<>"'/");
254 }
255
256 #[test]
257 fn preserves_unicode() {
258 assert_eq!(encode_safe("こんにちは"), "こんにちは");
259 assert!(matches!(encode_safe("こんにちは"), Cow::Borrowed(_)));
260 }
261
262 #[test]
263 fn mixed_unicode_and_special() {
264 assert_eq!(encode_safe("日本語&テスト"), "日本語&テスト");
265 }
266
267 #[test]
268 fn escapes_at_start() {
269 assert_eq!(encode_safe("&start"), "&start");
270 }
271
272 #[test]
273 fn escapes_at_end() {
274 assert_eq!(encode_safe("end&"), "end&");
275 }
276
277 #[test]
278 fn multiple_consecutive_escapes() {
279 assert_eq!(encode_safe("&&"), "&&");
280 }
281
282 #[test]
283 fn realistic_nonce() {
284 let nonce = "YWJjZGVmZ2hpamtsbW5vcA==";
286 let result = encode_safe(nonce);
287 assert!(matches!(result, Cow::Borrowed(_)));
288 }
289
290 #[test]
291 fn realistic_attribute_value() {
292 assert_eq!(
293 encode_safe("John O'Brien & Associates"),
294 "John O'Brien & Associates"
295 );
296 }
297
298 #[test]
299 fn realistic_xss_attempt() {
300 assert_eq!(
301 encode_safe("<script>alert('xss')</script>"),
302 "<script>alert('xss')</script>"
303 );
304 }
305
306 #[test]
307 fn escapes_all_known_special_chars() {
308 let test_cases = [
310 ("", ""),
311 ("hello", "hello"),
312 ("&", "&"),
313 ("<", "<"),
314 (">", ">"),
315 ("\"", """),
316 ("'", "'"),
317 ("/", "/"),
318 ("a&b<c>d\"e'f/g", "a&b<c>d"e'f/g"),
319 ];
320 for (input, expected) in test_cases {
321 assert_eq!(encode_safe(input), expected, "Failed for input: {input:?}");
322 }
323 }
324
325 #[test]
326 fn style_text_preserves_normal_bytes_in_one_write() {
327 let mut writer = StyleWriter::default();
328 write_style_text(&mut writer, ".card{content:'normal / style'}").unwrap();
329 assert_eq!(writer.output, ".card{content:'normal / style'}");
330 assert_eq!(writer.writes, 1);
331 }
332
333 #[test]
334 fn style_text_escapes_mixed_case_end_tags() {
335 let mut writer = StyleWriter::default();
336 write_style_text(
337 &mut writer,
338 ".a{content:'</style>'}.b{content:'</StYlE attr>'}",
339 )
340 .unwrap();
341 assert_eq!(
342 writer.output,
343 ".a{content:'<\\/style>'}.b{content:'<\\/StYlE attr>'}"
344 );
345 }
346
347 #[test]
348 fn style_text_escape_detection_matches_writer() {
349 let cases = [
350 ("", false),
351 ("</styl", false),
352 ("< /style", false),
353 ("<\\/style", false),
354 ("</style", true),
355 ("prefix</STYLE>suffix", true),
356 ("a</StYlE attr>b", true),
357 ];
358
359 for (css, expected) in cases {
360 assert_eq!(
361 style_text_needs_escape(css),
362 expected,
363 "unexpected detection for {css:?}"
364 );
365
366 let mut writer = StyleWriter::default();
367 write_style_text(&mut writer, css).unwrap();
368 assert_eq!(
369 writer.output != css,
370 expected,
371 "writer and detector diverged for {css:?}"
372 );
373 }
374 }
375}