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