use std::borrow::Cow;
use crate::{ResponseWriter, Result};
pub fn encode_safe(input: &str) -> Cow<'_, str> {
let bytes = input.as_bytes();
let first = bytes
.iter()
.position(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\'' | b'/'));
let Some(pos) = first else {
return Cow::Borrowed(input);
};
let mut out = String::with_capacity(input.len() + 6);
out.push_str(&input[..pos]);
let mut start = pos;
for (i, &b) in bytes[pos..].iter().enumerate() {
let replacement = match b {
b'&' => "&",
b'<' => "<",
b'>' => ">",
b'"' => """,
b'\'' => "'",
b'/' => "/",
_ => continue,
};
out.push_str(&input[start..pos + i]);
out.push_str(replacement);
start = pos + i + 1;
}
out.push_str(&input[start..]);
Cow::Owned(out)
}
#[inline]
fn is_style_end_tag(bytes: &[u8], index: usize) -> bool {
bytes[index] == b'<'
&& bytes[index + 1] == b'/'
&& bytes[index + 2].eq_ignore_ascii_case(&b's')
&& bytes[index + 3].eq_ignore_ascii_case(&b't')
&& bytes[index + 4].eq_ignore_ascii_case(&b'y')
&& bytes[index + 5].eq_ignore_ascii_case(&b'l')
&& bytes[index + 6].eq_ignore_ascii_case(&b'e')
}
pub(crate) fn style_text_needs_escape(css: &str) -> bool {
let bytes = css.as_bytes();
let mut index = 0usize;
while index + 7 <= bytes.len() {
if is_style_end_tag(bytes, index) {
return true;
}
index += 1;
}
false
}
pub(crate) fn write_style_text(writer: &mut dyn ResponseWriter, css: &str) -> Result<()> {
let bytes = css.as_bytes();
let mut chunk_start = 0usize;
let mut index = 0usize;
while index + 7 <= bytes.len() {
if is_style_end_tag(bytes, index) {
writer.write(&css[chunk_start..index + 1])?;
writer.write("\\/")?;
chunk_start = index + 2;
index += 7;
} else {
index += 1;
}
}
writer.write(&css[chunk_start..])
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct StyleWriter {
output: String,
writes: usize,
}
impl ResponseWriter for StyleWriter {
fn write(&mut self, content: &str) -> Result<()> {
self.output.push_str(content);
self.writes += 1;
Ok(())
}
fn end(&mut self) -> Result<()> {
Ok(())
}
}
#[test]
fn no_escaping_returns_borrowed() {
let input = "Hello World 123";
let result = encode_safe(input);
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(result, "Hello World 123");
}
#[test]
fn empty_string_returns_borrowed() {
let result = encode_safe("");
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(result, "");
}
#[test]
fn escapes_ampersand() {
assert_eq!(encode_safe("a&b"), "a&b");
}
#[test]
fn escapes_less_than() {
assert_eq!(encode_safe("<script>"), "<script>");
}
#[test]
fn escapes_greater_than() {
assert_eq!(encode_safe("a>b"), "a>b");
}
#[test]
fn escapes_double_quote() {
assert_eq!(encode_safe(r#"a"b"#), "a"b");
}
#[test]
fn escapes_single_quote() {
assert_eq!(encode_safe("a'b"), "a'b");
}
#[test]
fn escapes_forward_slash() {
assert_eq!(encode_safe("a/b"), "a/b");
}
#[test]
fn escapes_all_special_chars() {
assert_eq!(encode_safe(r#"&<>"'/"#), "&<>"'/");
}
#[test]
fn preserves_unicode() {
assert_eq!(encode_safe("こんにちは"), "こんにちは");
assert!(matches!(encode_safe("こんにちは"), Cow::Borrowed(_)));
}
#[test]
fn mixed_unicode_and_special() {
assert_eq!(encode_safe("日本語&テスト"), "日本語&テスト");
}
#[test]
fn escapes_at_start() {
assert_eq!(encode_safe("&start"), "&start");
}
#[test]
fn escapes_at_end() {
assert_eq!(encode_safe("end&"), "end&");
}
#[test]
fn multiple_consecutive_escapes() {
assert_eq!(encode_safe("&&"), "&&");
}
#[test]
fn realistic_nonce() {
let nonce = "YWJjZGVmZ2hpamtsbW5vcA==";
let result = encode_safe(nonce);
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn realistic_attribute_value() {
assert_eq!(
encode_safe("John O'Brien & Associates"),
"John O'Brien & Associates"
);
}
#[test]
fn realistic_xss_attempt() {
assert_eq!(
encode_safe("<script>alert('xss')</script>"),
"<script>alert('xss')</script>"
);
}
#[test]
fn escapes_all_known_special_chars() {
let test_cases = [
("", ""),
("hello", "hello"),
("&", "&"),
("<", "<"),
(">", ">"),
("\"", """),
("'", "'"),
("/", "/"),
("a&b<c>d\"e'f/g", "a&b<c>d"e'f/g"),
];
for (input, expected) in test_cases {
assert_eq!(encode_safe(input), expected, "Failed for input: {input:?}");
}
}
#[test]
fn style_text_preserves_normal_bytes_in_one_write() {
let mut writer = StyleWriter::default();
write_style_text(&mut writer, ".card{content:'normal / style'}").unwrap();
assert_eq!(writer.output, ".card{content:'normal / style'}");
assert_eq!(writer.writes, 1);
}
#[test]
fn style_text_escapes_mixed_case_end_tags() {
let mut writer = StyleWriter::default();
write_style_text(
&mut writer,
".a{content:'</style>'}.b{content:'</StYlE attr>'}",
)
.unwrap();
assert_eq!(
writer.output,
".a{content:'<\\/style>'}.b{content:'<\\/StYlE attr>'}"
);
}
#[test]
fn style_text_escape_detection_matches_writer() {
let cases = [
("", false),
("</styl", false),
("< /style", false),
("<\\/style", false),
("</style", true),
("prefix</STYLE>suffix", true),
("a</StYlE attr>b", true),
];
for (css, expected) in cases {
assert_eq!(
style_text_needs_escape(css),
expected,
"unexpected detection for {css:?}"
);
let mut writer = StyleWriter::default();
write_style_text(&mut writer, css).unwrap();
assert_eq!(
writer.output != css,
expected,
"writer and detector diverged for {css:?}"
);
}
}
}