use std::borrow::Cow;
fn text_entity(byte: u8) -> Option<&'static str> {
match byte {
b'&' => Some("&"),
b'<' => Some("<"),
b'>' => Some(">"),
b'"' => Some("""),
b'\'' => Some("'"),
_ => None,
}
}
fn text_entity_with_slashes(byte: u8) -> Option<&'static str> {
if byte == b'/' {
Some("/")
} else {
text_entity(byte)
}
}
fn attribute_entity(byte: u8) -> Option<&'static str> {
match byte {
b'&' => Some("&"),
b'"' => Some("""),
b'\'' => Some("'"),
_ => None,
}
}
fn xml_entity(byte: u8) -> Option<&'static str> {
if byte == b'\'' {
Some("'")
} else {
text_entity(byte)
}
}
fn write_runs(out: &mut String, input: &str, entity: impl Fn(u8) -> Option<&'static str>) {
let mut start = 0;
for (index, byte) in input.bytes().enumerate() {
if let Some(replacement) = entity(byte) {
if index > start {
out.push_str(&input[start..index]);
}
out.push_str(replacement);
start = index + 1;
}
}
out.push_str(&input[start..]);
}
pub(crate) struct TextWriter<'a>(pub(crate) &'a mut String);
impl std::fmt::Write for TextWriter<'_> {
fn write_str(&mut self, piece: &str) -> std::fmt::Result {
write_escaped_text(self.0, piece, false);
Ok(())
}
}
pub(crate) struct AttributeWriter<'a>(pub(crate) &'a mut String);
impl std::fmt::Write for AttributeWriter<'_> {
fn write_str(&mut self, piece: &str) -> std::fmt::Result {
write_escaped_attribute(self.0, piece);
Ok(())
}
}
fn escape_with(input: &str, entity: impl Fn(u8) -> Option<&'static str> + Copy) -> Cow<'_, str> {
let Some(first) = input.bytes().position(|byte| entity(byte).is_some()) else {
return Cow::Borrowed(input);
};
let mut out = String::with_capacity(input.len() + input.len() / 4 + 16);
out.push_str(&input[..first]);
write_runs(&mut out, &input[first..], entity);
Cow::Owned(out)
}
#[must_use]
pub fn escape_text(input: &str) -> Cow<'_, str> {
escape_with(input, text_entity)
}
#[must_use]
pub fn escape_text_with_slashes(input: &str) -> Cow<'_, str> {
escape_with(input, text_entity_with_slashes)
}
#[must_use]
pub fn escape_attribute(input: &str) -> Cow<'_, str> {
escape_with(input, attribute_entity)
}
#[must_use]
pub fn escape_xml(input: &str) -> Cow<'_, str> {
if !input.chars().any(needs_xml_change) {
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len() + input.len() / 4 + 16);
for c in input.chars() {
if is_xml_illegal(c) {
continue;
}
match u8::try_from(c).ok().and_then(xml_entity) {
Some(entity) => out.push_str(entity),
None => out.push(c),
}
}
Cow::Owned(out)
}
fn is_xml_illegal(c: char) -> bool {
matches!(c, '\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}')
}
fn needs_xml_change(c: char) -> bool {
is_xml_illegal(c) || u8::try_from(c).ok().and_then(xml_entity).is_some()
}
#[must_use]
pub fn escape_json_script(json: &str) -> Cow<'_, str> {
fn entity(c: char) -> Option<&'static str> {
match c {
'<' => Some("\\u003c"),
'>' => Some("\\u003e"),
'&' => Some("\\u0026"),
'\u{2028}' => Some("\\u2028"),
'\u{2029}' => Some("\\u2029"),
_ => None,
}
}
if !json.chars().any(|c| entity(c).is_some()) {
return Cow::Borrowed(json);
}
let mut out = String::with_capacity(json.len() + json.len() / 8 + 16);
for c in json.chars() {
match entity(c) {
Some(escaped) => out.push_str(escaped),
None => out.push(c),
}
}
Cow::Owned(out)
}
pub(crate) fn write_escaped_text(out: &mut String, input: &str, escape_slashes: bool) {
out.reserve(input.len());
write_runs(
out,
input,
if escape_slashes {
text_entity_with_slashes
} else {
text_entity
},
);
}
pub(crate) fn write_escaped_attribute(out: &mut String, input: &str) {
out.reserve(input.len());
write_runs(out, input, attribute_entity);
}
#[cfg(test)]
mod tests {
#[test]
fn xml_drops_exactly_the_characters_xml_1_0_cannot_hold() {
for illegal in [
'\u{0}', '\u{8}', '\u{B}', '\u{C}', '\u{E}', '\u{1F}', '\u{FFFE}', '\u{FFFF}',
] {
assert_eq!(
escape_xml(&format!("a{illegal}b")),
"ab",
"U+{:04X}",
illegal as u32
);
}
for legal in [
'\t',
'\n',
'\r',
' ',
'\u{7F}',
'\u{80}',
'\u{FFFD}',
'\u{10000}',
'é',
] {
assert_eq!(
escape_xml(&format!("a{legal}b")),
format!("a{legal}b"),
"U+{:04X}",
legal as u32
);
}
assert!(matches!(escape_xml("plain"), std::borrow::Cow::Borrowed(_)));
assert_eq!(escape_xml("<\u{0}&"), "<&");
}
#[test]
fn clean_input_is_returned_without_allocating() {
use std::borrow::Cow;
for clean in ["", "plain text", "ação — 日本語 ✓", "2026/09/16"] {
assert!(matches!(escape_text(clean), Cow::Borrowed(_)), "{clean:?}");
assert!(
matches!(escape_attribute(clean), Cow::Borrowed(_)),
"{clean:?}"
);
assert!(matches!(escape_xml(clean), Cow::Borrowed(_)), "{clean:?}");
}
assert!(matches!(escape_text("a & b"), Cow::Owned(_)));
assert!(matches!(escape_text_with_slashes("a/b"), Cow::Owned(_)));
assert!(matches!(escape_attribute("a < b"), Cow::Borrowed(_)));
}
#[test]
fn byte_scanning_finds_specials_beside_multibyte_characters() {
assert_eq!(escape_text("é<é"), "é<é");
assert_eq!(escape_attribute("日\"本"), "日"本");
assert_eq!(escape_xml("✓'✓"), "✓'✓");
}
use super::*;
#[test]
fn escapes_the_five_html_characters() {
assert_eq!(escape_text("<>&\"'"), "<>&"'");
}
#[test]
fn escapes_a_script_payload() {
assert_eq!(
escape_text("<script>alert('XSS')</script>"),
"<script>alert('XSS')</script>"
);
}
#[test]
fn ampersand_is_escaped_before_the_entities_it_introduces() {
assert_eq!(escape_text("a & b < c"), "a & b < c");
}
#[test]
fn slashes_are_only_escaped_on_request() {
assert_eq!(escape_text("2026/09/16"), "2026/09/16");
assert_eq!(
escape_text_with_slashes("2026/09/16"),
"2026/09/16"
);
}
#[test]
fn attribute_escaping_leaves_angle_brackets_alone() {
assert_eq!(
escape_attribute(r#"a "b" & <c>"#),
"a "b" & <c>"
);
}
#[test]
fn xml_uses_the_apos_entity_where_html_uses_a_numeric_reference() {
assert_eq!(escape_xml("it's"), "it's");
assert_eq!(escape_text("it's"), "it's");
}
#[test]
fn escaping_is_not_idempotent_by_design() {
assert_eq!(escape_text(&escape_text("a & b")), "a &amp; b");
}
#[test]
fn output_never_contains_a_bare_angle_bracket() {
for input in ["<", "<<>>", "a<b>c", "&<>\"'"] {
let escaped = escape_text(input);
assert!(!escaped.contains('<'), "{input:?} produced {escaped:?}");
assert!(!escaped.contains('>'), "{input:?} produced {escaped:?}");
}
}
#[test]
fn non_ascii_passes_through_untouched() {
assert_eq!(escape_text("R$ 9,90/mês — ação"), "R$ 9,90/mês — ação");
}
#[test]
fn empty_input_produces_empty_output() {
assert_eq!(escape_text(""), "");
assert_eq!(escape_attribute(""), "");
assert_eq!(escape_xml(""), "");
}
#[test]
fn an_ampersand_becomes_an_entity() {
assert_eq!(escape_text("Tom & Jerry"), "Tom & Jerry");
}
#[test]
fn double_quotes_become_entities() {
assert_eq!(
escape_text(r#"He said "Hello""#),
"He said "Hello""
);
}
}