Skip to main content

serde_xml/
escape.rs

1//! XML escape and unescape utilities.
2//!
3//! This module provides fast, allocation-minimizing functions for escaping
4//! and unescaping XML special characters.
5
6use memchr::{memchr, memchr2, memchr3};
7
8/// Finds the first XML special character (`<`, `>`, `&`, `"`, `'`) using
9/// memchr's SIMD-accelerated search (a 3-byte scan for `< > &` combined with
10/// a 2-byte scan for `" '`).
11///
12/// Shared by the in-memory escapers here and `XmlWriter`'s streaming escaper.
13#[inline(always)]
14pub(crate) fn find_special(bytes: &[u8]) -> Option<usize> {
15    match (memchr3(b'<', b'>', b'&', bytes), memchr2(b'"', b'\'', bytes)) {
16        (Some(a), Some(b)) => Some(a.min(b)),
17        (a, b) => a.or(b),
18    }
19}
20
21/// Returns the XML entity for one of the special bytes reported by
22/// [`find_special`].
23///
24/// # Panics
25///
26/// Panics if `b` is not one of `< > & " '`; callers must only pass bytes
27/// located by [`find_special`].
28#[inline(always)]
29pub(crate) fn entity_for(b: u8) -> &'static str {
30    match b {
31        b'<' => "&lt;",
32        b'>' => "&gt;",
33        b'&' => "&amp;",
34        b'"' => "&quot;",
35        b'\'' => "&apos;",
36        _ => unreachable!("find_special only returns special characters"),
37    }
38}
39
40/// Escapes XML special characters in a string.
41///
42/// Returns a `Cow<str>` to avoid allocation when no escaping is needed.
43#[inline]
44pub fn escape(s: &str) -> std::borrow::Cow<'_, str> {
45    let bytes = s.as_bytes();
46
47    // Fast path: scan for any character needing escape using memchr
48    if find_special(bytes).is_none() {
49        return std::borrow::Cow::Borrowed(s);
50    }
51
52    let mut result = String::with_capacity(s.len() + s.len() / 8);
53    escape_to_inner(bytes, &mut result);
54    std::borrow::Cow::Owned(result)
55}
56
57/// Escapes XML special characters and appends to the given string.
58#[inline]
59pub fn escape_to(s: &str, out: &mut String) {
60    escape_to_inner(s.as_bytes(), out);
61}
62
63/// Internal escape implementation - memchr-accelerated span copying.
64#[inline(always)]
65fn escape_to_inner(bytes: &[u8], out: &mut String) {
66    let mut start = 0;
67
68    while let Some(offset) = find_special(&bytes[start..]) {
69        let i = start + offset;
70        let escaped = entity_for(bytes[i]);
71
72        // Batch append non-escaped bytes
73        if start < i {
74            // SAFETY: Only escaping ASCII chars, so UTF-8 boundaries are preserved
75            out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..i]) });
76        }
77        out.push_str(escaped);
78        start = i + 1;
79    }
80
81    // Append remaining
82    if start < bytes.len() {
83        out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..]) });
84    }
85}
86
87/// Unescapes XML entities in a string.
88///
89/// Returns a `Cow<str>` to avoid allocation when no unescaping is needed.
90#[inline]
91pub fn unescape(s: &str) -> Result<std::borrow::Cow<'_, str>, UnescapeError> {
92    let bytes = s.as_bytes();
93
94    // Fast path: check if any unescaping is needed using memchr
95    match memchr(b'&', bytes) {
96        None => Ok(std::borrow::Cow::Borrowed(s)),
97        Some(first_amp) => {
98            let mut result = String::with_capacity(s.len());
99            // Add everything before the first &
100            if first_amp > 0 {
101                result.push_str(unsafe {
102                    std::str::from_utf8_unchecked(&bytes[..first_amp])
103                });
104            }
105            unescape_from(bytes, first_amp, &mut result)?;
106            Ok(std::borrow::Cow::Owned(result))
107        }
108    }
109}
110
111/// Error type for unescape operations.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct UnescapeError {
114    /// The invalid entity that caused the error.
115    pub entity: String,
116    /// Position in the input where the error occurred.
117    pub position: usize,
118}
119
120impl std::fmt::Display for UnescapeError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        write!(f, "invalid XML entity '{}' at position {}", self.entity, self.position)
123    }
124}
125
126impl std::error::Error for UnescapeError {}
127
128/// Internal unescape starting from a position known to have '&'.
129#[inline(always)]
130fn unescape_from(bytes: &[u8], start: usize, out: &mut String) -> Result<(), UnescapeError> {
131    let mut i = start;
132
133    while i < bytes.len() {
134        if bytes[i] == b'&' {
135            let entity_start = i;
136            i += 1;
137
138            // Find semicolon using memchr for speed
139            match memchr(b';', &bytes[i..]) {
140                Some(len) if len > 0 && len <= 10 => {
141                    let entity = unsafe {
142                        std::str::from_utf8_unchecked(&bytes[i..i + len])
143                    };
144
145                    if let Some(c) = decode_entity_fast(entity) {
146                        out.push(c);
147                        i += len + 1;
148
149                        // Find and append text until next &
150                        if let Some(next_amp) = memchr(b'&', &bytes[i..]) {
151                            if next_amp > 0 {
152                                out.push_str(unsafe {
153                                    std::str::from_utf8_unchecked(&bytes[i..i + next_amp])
154                                });
155                            }
156                            i += next_amp;
157                        } else {
158                            // No more entities
159                            out.push_str(unsafe {
160                                std::str::from_utf8_unchecked(&bytes[i..])
161                            });
162                            return Ok(());
163                        }
164                    } else {
165                        return Err(UnescapeError {
166                            entity: format!("&{};", entity),
167                            position: entity_start,
168                        });
169                    }
170                }
171                _ => {
172                    return Err(UnescapeError {
173                        entity: String::from("&"),
174                        position: entity_start,
175                    });
176                }
177            }
178        } else {
179            i += 1;
180        }
181    }
182
183    Ok(())
184}
185
186/// Fast entity decoder with common cases first.
187#[inline(always)]
188fn decode_entity_fast(entity: &str) -> Option<char> {
189    // Check length first to avoid string comparisons
190    match entity.len() {
191        2 => match entity {
192            "lt" => Some('<'),
193            "gt" => Some('>'),
194            _ => decode_numeric_entity(entity),
195        },
196        3 => match entity {
197            "amp" => Some('&'),
198            _ => decode_numeric_entity(entity),
199        },
200        4 => match entity {
201            "quot" => Some('"'),
202            "apos" => Some('\''),
203            _ => decode_numeric_entity(entity),
204        },
205        _ => decode_numeric_entity(entity),
206    }
207}
208
209/// Decodes a numeric character reference (&#NNN; or &#xHHH;).
210#[inline]
211fn decode_numeric_entity(entity: &str) -> Option<char> {
212    let bytes = entity.as_bytes();
213    if bytes.is_empty() || bytes[0] != b'#' {
214        return None;
215    }
216
217    let (radix, digits) = if bytes.len() > 1 && (bytes[1] == b'x' || bytes[1] == b'X') {
218        (16, &entity[2..])
219    } else {
220        (10, &entity[1..])
221    };
222
223    if digits.is_empty() {
224        return None;
225    }
226
227    let code = u32::from_str_radix(digits, radix).ok()?;
228    char::from_u32(code)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_escape_no_special_chars() {
237        let s = "Hello, World!";
238        let escaped = escape(s);
239        assert!(matches!(escaped, std::borrow::Cow::Borrowed(_)));
240        assert_eq!(escaped, s);
241    }
242
243    #[test]
244    fn test_escape_lt() {
245        assert_eq!(escape("<"), "&lt;");
246    }
247
248    #[test]
249    fn test_escape_gt() {
250        assert_eq!(escape(">"), "&gt;");
251    }
252
253    #[test]
254    fn test_escape_amp() {
255        assert_eq!(escape("&"), "&amp;");
256    }
257
258    #[test]
259    fn test_escape_quot() {
260        assert_eq!(escape("\""), "&quot;");
261    }
262
263    #[test]
264    fn test_escape_apos() {
265        assert_eq!(escape("'"), "&apos;");
266    }
267
268    #[test]
269    fn test_escape_mixed() {
270        assert_eq!(
271            escape("<div class=\"foo\">Hello & goodbye</div>"),
272            "&lt;div class=&quot;foo&quot;&gt;Hello &amp; goodbye&lt;/div&gt;"
273        );
274    }
275
276    #[test]
277    fn test_escape_long_mixed_offsets() {
278        // Long string with special characters at the start, middle, end,
279        // and adjacent to each other, separated by long clean spans.
280        let filler = "a".repeat(100);
281        let input = format!("<{filler}&{filler}\"'{filler}>");
282        let expected = format!("&lt;{filler}&amp;{filler}&quot;&apos;{filler}&gt;");
283        assert_eq!(escape(&input), expected);
284    }
285
286    #[test]
287    fn test_escape_only_quotes() {
288        // Exercises the quote/apostrophe (memchr2) arm of find_special without
289        // any angle-bracket or ampersand hit
290        assert_eq!(escape("say \"hi\" and 'bye'"), "say &quot;hi&quot; and &apos;bye&apos;");
291    }
292
293    #[test]
294    fn test_unescape_no_entities() {
295        let s = "Hello, World!";
296        let unescaped = unescape(s).unwrap();
297        assert!(matches!(unescaped, std::borrow::Cow::Borrowed(_)));
298        assert_eq!(unescaped, s);
299    }
300
301    #[test]
302    fn test_unescape_lt() {
303        assert_eq!(unescape("&lt;").unwrap(), "<");
304    }
305
306    #[test]
307    fn test_unescape_gt() {
308        assert_eq!(unescape("&gt;").unwrap(), ">");
309    }
310
311    #[test]
312    fn test_unescape_amp() {
313        assert_eq!(unescape("&amp;").unwrap(), "&");
314    }
315
316    #[test]
317    fn test_unescape_quot() {
318        assert_eq!(unescape("&quot;").unwrap(), "\"");
319    }
320
321    #[test]
322    fn test_unescape_apos() {
323        assert_eq!(unescape("&apos;").unwrap(), "'");
324    }
325
326    #[test]
327    fn test_unescape_mixed() {
328        assert_eq!(
329            unescape("&lt;div class=&quot;foo&quot;&gt;Hello &amp; goodbye&lt;/div&gt;").unwrap(),
330            "<div class=\"foo\">Hello & goodbye</div>"
331        );
332    }
333
334    #[test]
335    fn test_unescape_numeric_decimal() {
336        assert_eq!(unescape("&#65;").unwrap(), "A");
337        assert_eq!(unescape("&#97;").unwrap(), "a");
338        assert_eq!(unescape("&#8364;").unwrap(), "€");
339    }
340
341    #[test]
342    fn test_unescape_numeric_hex() {
343        assert_eq!(unescape("&#x41;").unwrap(), "A");
344        assert_eq!(unescape("&#x61;").unwrap(), "a");
345        assert_eq!(unescape("&#x20AC;").unwrap(), "€");
346    }
347
348    #[test]
349    fn test_unescape_invalid_entity() {
350        let result = unescape("&invalid;");
351        assert!(result.is_err());
352        let err = result.unwrap_err();
353        assert_eq!(err.entity, "&invalid;");
354        assert_eq!(err.position, 0);
355    }
356
357    #[test]
358    fn test_unescape_unterminated_entity() {
359        let result = unescape("&lt");
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn test_escape_to() {
365        let mut out = String::new();
366        escape_to("<test>", &mut out);
367        assert_eq!(out, "&lt;test&gt;");
368    }
369
370    #[test]
371    fn test_roundtrip() {
372        let original = "<div class=\"foo\">Hello & goodbye</div>";
373        let escaped = escape(original);
374        let unescaped = unescape(&escaped).unwrap();
375        assert_eq!(unescaped, original);
376    }
377}