Skip to main content

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 `&#x27;` |
10//! | [`escape_attribute`] | `&` `"` `'` | **not** `<` or `>` |
11//! | [`escape_xml`] | `&` `<` `>` `"` `'` | `'` becomes `&apos;`, 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 `&#x2F;`. 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///     "&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;"
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&#x2F;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 &quot;b&quot; &amp; <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 `&apos;`, which is an XML
73/// entity, rather than `&#x27;`.
74///
75/// # Examples
76/// ```
77/// use winged_rust::core::escape::escape_xml;
78/// assert_eq!(escape_xml("Tom & Jerry's <show>"), "Tom &amp; Jerry&apos;s &lt;show&gt;");
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("&amp;"),
86            '<' => out.push_str("&lt;"),
87            '>' => out.push_str("&gt;"),
88            '"' => out.push_str("&quot;"),
89            '\'' => out.push_str("&apos;"),
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("&amp;"),
105            '<' => out.push_str("&lt;"),
106            '>' => out.push_str("&gt;"),
107            '"' => out.push_str("&quot;"),
108            '\'' => out.push_str("&#x27;"),
109            '/' if escape_slashes => out.push_str("&#x2F;"),
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("&amp;"),
121            '"' => out.push_str("&quot;"),
122            '\'' => out.push_str("&#x27;"),
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("<>&\"'"), "&lt;&gt;&amp;&quot;&#x27;");
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            "&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;"
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 &amp; b &lt; 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&#x2F;09&#x2F;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 &quot;b&quot; &amp; <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&apos;s");
178        assert_eq!(escape_text("it's"), "it&#x27;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;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 &amp; 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 &quot;Hello&quot;"
223        );
224    }
225}