Skip to main content

nextcloud_dav/xml/
escape.rs

1//! Text and attribute-value escaping.
2
3/// Escapes the five characters that are never literal in XML content.
4///
5/// Used for both element text and attribute values, so a single function is
6/// enough: `&`, `<`, `>` plus both quote kinds.
7pub fn escape_text(value: &str) -> String {
8    let mut escaped = String::with_capacity(value.len());
9    for character in value.chars() {
10        match character {
11            '&' => escaped.push_str("&amp;"),
12            '<' => escaped.push_str("&lt;"),
13            '>' => escaped.push_str("&gt;"),
14            '"' => escaped.push_str("&quot;"),
15            '\'' => escaped.push_str("&apos;"),
16            other => escaped.push(other),
17        }
18    }
19    escaped
20}
21
22/// Reverses [`escape_text`], including numeric character references.
23///
24/// An entity the function does not recognise is left in place verbatim, so a
25/// body with an unknown `&foo;` survives instead of losing characters.
26pub fn unescape_text(value: &str) -> String {
27    let mut decoded = String::with_capacity(value.len());
28    let mut rest = value;
29    while let Some(index) = rest.find('&') {
30        decoded.push_str(&rest[..index]);
31        rest = &rest[index..];
32        match decode_entity(rest) {
33            Some((character, consumed)) => {
34                decoded.push(character);
35                rest = &rest[consumed..];
36            }
37            None => {
38                decoded.push('&');
39                rest = &rest[1..];
40            }
41        }
42    }
43    decoded.push_str(rest);
44    decoded
45}
46
47/// Decodes one `&...;` reference at the start of `input`, returning the
48/// character and the number of bytes consumed.
49fn decode_entity(input: &str) -> Option<(char, usize)> {
50    let end = input.find(';')?;
51    // A reference longer than this is not a character reference, it is a
52    // stray `&` followed by prose.
53    if end > 12 {
54        return None;
55    }
56    let body = &input[1..end];
57    let character = match body {
58        "amp" => '&',
59        "lt" => '<',
60        "gt" => '>',
61        "quot" => '"',
62        "apos" => '\'',
63        numeric => char::from_u32(decode_number(numeric)?)?,
64    };
65    Some((character, end + 1))
66}
67
68fn decode_number(body: &str) -> Option<u32> {
69    let (digits, radix) = match body.strip_prefix("#x").or_else(|| body.strip_prefix("#X")) {
70        Some(hex) => (hex, 16),
71        None => (body.strip_prefix('#')?, 10),
72    };
73    u32::from_str_radix(digits, radix).ok()
74}
75
76#[cfg(test)]
77mod tests {
78    use super::{escape_text, unescape_text};
79
80    #[test]
81    fn escapes_every_special_character() {
82        assert_eq!(
83            escape_text("a & b < c > d \" e ' f"),
84            "a &amp; b &lt; c &gt; d &quot; e &apos; f"
85        );
86    }
87
88    #[test]
89    fn escapes_a_filename_with_ampersand_and_angle_bracket() {
90        assert_eq!(
91            escape_text("Q1 & Q2 <final>.txt"),
92            "Q1 &amp; Q2 &lt;final&gt;.txt"
93        );
94    }
95
96    #[test]
97    fn leaves_ordinary_text_untouched() {
98        assert_eq!(escape_text("Photos/2024"), "Photos/2024");
99    }
100
101    #[test]
102    fn round_trips_escaped_text() {
103        let original = "Tom & Jerry's \"best\" <take>";
104        assert_eq!(unescape_text(&escape_text(original)), original);
105    }
106
107    #[test]
108    fn decodes_numeric_references() {
109        assert_eq!(unescape_text("&#65;&#x42;"), "AB");
110    }
111
112    #[test]
113    fn keeps_unknown_entities_verbatim() {
114        assert_eq!(unescape_text("a &unknown; b"), "a &unknown; b");
115        assert_eq!(unescape_text("a & b"), "a & b");
116    }
117}