winged_rust/core/escape.rs
1//! HTML and XML escaping.
2//!
3//! Winged-Swift has three distinct escapers and the golden fixtures depend on the
4//! differences between them. `WINGED_RUST_SPEC.md` §3.3 shows only one; following the
5//! spec here would break parity.
6//!
7//! | function | escapes | notes |
8//! | --- | --- | --- |
9//! | [`escape_text`] | `&` `<` `>` `"` `'` | `&` first; `'` becomes `'` |
10//! | [`escape_attribute`] | `&` `"` `'` | **not** `<` or `>` |
11//! | [`escape_xml`] | `&` `<` `>` `"` `'` | `'` becomes `'`, the XML spelling |
12//!
13//! Escaping is applied **once, when content enters the tree** — never at render time.
14//! That mirrors Winged-Swift, where `HTMLTag.init` stores already-escaped content, and it
15//! is why [`crate::core::Node::Raw`] exists as the documented escape hatch.
16
17/// Escapes text for use as HTML element content.
18///
19/// Set `escape_slashes` to also turn `/` into `/`. It defaults to off in
20/// [`escape_text`] because `&`, `<`, `>`, `"` and `'` already close the XSS surface, and
21/// escaping every slash turns dates, paths and "and/or" into unreadable entities.
22///
23/// # Examples
24/// ```
25/// use winged_rust::core::escape::escape_text;
26/// assert_eq!(
27/// escape_text("<script>alert('XSS')</script>"),
28/// "<script>alert('XSS')</script>"
29/// );
30/// ```
31#[must_use]
32pub fn escape_text(input: &str) -> String {
33 let mut out = String::with_capacity(input.len());
34 write_escaped_text(&mut out, input, false);
35 out
36}
37
38/// Escapes text for HTML content, optionally escaping `/` as well.
39///
40/// # Examples
41/// ```
42/// use winged_rust::core::escape::escape_text_with_slashes;
43/// assert_eq!(escape_text_with_slashes("a/b"), "a/b");
44/// ```
45#[must_use]
46pub fn escape_text_with_slashes(input: &str) -> String {
47 let mut out = String::with_capacity(input.len());
48 write_escaped_text(&mut out, input, true);
49 out
50}
51
52/// Escapes a value for use inside a double-quoted HTML attribute.
53///
54/// Deliberately narrower than [`escape_text`]: `<` and `>` are left alone, because they
55/// cannot terminate a quoted attribute value. Winged-Swift's `HTMLEscape.escapeAttribute`
56/// does the same, and `marketing-pretty.html` contains attribute values that prove it.
57///
58/// # Examples
59/// ```
60/// use winged_rust::core::escape::escape_attribute;
61/// assert_eq!(escape_attribute(r#"a "b" & <c>"#), "a "b" & <c>");
62/// ```
63#[must_use]
64pub fn escape_attribute(input: &str) -> String {
65 let mut out = String::with_capacity(input.len());
66 write_escaped_attribute(&mut out, input);
67 out
68}
69
70/// Escapes text for XML content — used by the sitemap and RSS generators.
71///
72/// Differs from [`escape_text`] in one character: `'` becomes `'`, which is an XML
73/// entity, rather than `'`.
74///
75/// # Examples
76/// ```
77/// use winged_rust::core::escape::escape_xml;
78/// assert_eq!(escape_xml("Tom & Jerry's <show>"), "Tom & Jerry's <show>");
79/// ```
80#[must_use]
81pub fn escape_xml(input: &str) -> String {
82 let mut out = String::with_capacity(input.len());
83 for c in input.chars() {
84 match c {
85 '&' => out.push_str("&"),
86 '<' => out.push_str("<"),
87 '>' => out.push_str(">"),
88 '"' => out.push_str("""),
89 '\'' => out.push_str("'"),
90 _ => out.push(c),
91 }
92 }
93 out
94}
95
96/// Appends escaped HTML text to an existing buffer.
97///
98/// The whole renderer writes into one buffer; allocating a fresh `String` per escape
99/// would defeat that.
100pub(crate) fn write_escaped_text(out: &mut String, input: &str, escape_slashes: bool) {
101 out.reserve(input.len());
102 for c in input.chars() {
103 match c {
104 '&' => out.push_str("&"),
105 '<' => out.push_str("<"),
106 '>' => out.push_str(">"),
107 '"' => out.push_str("""),
108 '\'' => out.push_str("'"),
109 '/' if escape_slashes => out.push_str("/"),
110 _ => out.push(c),
111 }
112 }
113}
114
115/// Appends an escaped attribute value to an existing buffer.
116pub(crate) fn write_escaped_attribute(out: &mut String, input: &str) {
117 out.reserve(input.len());
118 for c in input.chars() {
119 match c {
120 '&' => out.push_str("&"),
121 '"' => out.push_str("""),
122 '\'' => out.push_str("'"),
123 _ => out.push(c),
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 /// No single Swift counterpart: this covers in one assertion what `testEscapeAmpersand`
133 /// and `testEscapeQuotes` check separately, plus the angle brackets.
134 #[test]
135 fn escapes_the_five_html_characters() {
136 assert_eq!(escape_text("<>&\"'"), "<>&"'");
137 }
138
139 /// Ports `HTMLEscapeTests.testEscapeBasicHTML`.
140 #[test]
141 fn escapes_a_script_payload() {
142 assert_eq!(
143 escape_text("<script>alert('XSS')</script>"),
144 "<script>alert('XSS')</script>"
145 );
146 }
147
148 /// The ampersand must be replaced first, or every other entity gets double-escaped.
149 #[test]
150 fn ampersand_is_escaped_before_the_entities_it_introduces() {
151 assert_eq!(escape_text("a & b < c"), "a & b < c");
152 }
153
154 /// Ports `HTMLEscapeTests.testSlashesAreKeptByDefault` and
155 /// `HTMLEscapeTests.testSlashesCanBeEscapedExplicitly`.
156 #[test]
157 fn slashes_are_only_escaped_on_request() {
158 assert_eq!(escape_text("2026/09/16"), "2026/09/16");
159 assert_eq!(
160 escape_text_with_slashes("2026/09/16"),
161 "2026/09/16"
162 );
163 }
164
165 /// Ports `HTMLEscapeTests.testAttributeEscape`. Attribute context leaves `<` and `>`
166 /// alone — they cannot terminate a quoted value.
167 #[test]
168 fn attribute_escaping_leaves_angle_brackets_alone() {
169 assert_eq!(
170 escape_attribute(r#"a "b" & <c>"#),
171 "a "b" & <c>"
172 );
173 }
174
175 #[test]
176 fn xml_uses_the_apos_entity_where_html_uses_a_numeric_reference() {
177 assert_eq!(escape_xml("it's"), "it's");
178 assert_eq!(escape_text("it's"), "it's");
179 }
180
181 /// Escaping is applied exactly once, by the caller, at construction. Applying it
182 /// twice double-escapes — the same behaviour Winged-Swift has, and the reason the
183 /// builder never re-escapes an existing attribute value.
184 #[test]
185 fn escaping_is_not_idempotent_by_design() {
186 assert_eq!(escape_text(&escape_text("a & b")), "a &amp; b");
187 }
188
189 /// Nothing survives that could open a tag or start an entity.
190 #[test]
191 fn output_never_contains_a_bare_angle_bracket() {
192 for input in ["<", "<<>>", "a<b>c", "&<>\"'"] {
193 let escaped = escape_text(input);
194 assert!(!escaped.contains('<'), "{input:?} produced {escaped:?}");
195 assert!(!escaped.contains('>'), "{input:?} produced {escaped:?}");
196 }
197 }
198
199 #[test]
200 fn non_ascii_passes_through_untouched() {
201 assert_eq!(escape_text("R$ 9,90/mês — ação"), "R$ 9,90/mês — ação");
202 }
203
204 #[test]
205 fn empty_input_produces_empty_output() {
206 assert_eq!(escape_text(""), "");
207 assert_eq!(escape_attribute(""), "");
208 assert_eq!(escape_xml(""), "");
209 }
210
211 /// Ports `HTMLEscapeTests.testEscapeAmpersand`.
212 #[test]
213 fn an_ampersand_becomes_an_entity() {
214 assert_eq!(escape_text("Tom & Jerry"), "Tom & Jerry");
215 }
216
217 /// Ports `HTMLEscapeTests.testEscapeQuotes`.
218 #[test]
219 fn double_quotes_become_entities() {
220 assert_eq!(
221 escape_text(r#"He said "Hello""#),
222 "He said "Hello""
223 );
224 }
225}