#[must_use]
pub fn escape_text(input: &str) -> String {
let mut out = String::with_capacity(input.len());
write_escaped_text(&mut out, input, false);
out
}
#[must_use]
pub fn escape_text_with_slashes(input: &str) -> String {
let mut out = String::with_capacity(input.len());
write_escaped_text(&mut out, input, true);
out
}
#[must_use]
pub fn escape_attribute(input: &str) -> String {
let mut out = String::with_capacity(input.len());
write_escaped_attribute(&mut out, input);
out
}
#[must_use]
pub fn escape_xml(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
pub(crate) fn write_escaped_text(out: &mut String, input: &str, escape_slashes: bool) {
out.reserve(input.len());
for c in input.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'/' if escape_slashes => out.push_str("/"),
_ => out.push(c),
}
}
}
pub(crate) fn write_escaped_attribute(out: &mut String, input: &str) {
out.reserve(input.len());
for c in input.chars() {
match c {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
}
#[cfg(test)]
mod tests {
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""
);
}
}