Repository: tafia/quick-xml
Pull request #771 (2024-06-26 18:51:27)

Fixes in unescape routine
This PR fixes 2 errors that I found when working on a new way to represent entity references:
1. Currently, `quick_xml` does not allow too long numbers to represent character references. It, however, does not takes in account, that any number of leading zeroes are allowed, so the real character entity length can be any. [Conformance](https://github.com/belingueres/xml-conformance) test suite contains such tests.
2. #739 introduced regression where `Attribute::unescape_value` no longer unescaped predefined entities. That was fixed and checked that no similar places remains

[Corresponding test](https://dev.w3.org/cvsweb/2001/XML-Test-Suite/xmlconf/xmltest/valid/sa/042.xml?rev=1.1.1.1) (xmltest\valid\sa\042.xml -- one of):
```xml
<!DOCTYPE doc [
<!ELEMENT doc (#PCDATA)>
]>
<doc>&#00000000000000000000000000000000065;</doc>
```

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/tafia/quick-xml/pull/771?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
Attention: Patch coverage is `65.51724%` with `10 lines` in your changes missing coverage. Please review.
> Project coverage is 60.14%. Comparing base [(`7558577`)](https://app.codecov.io/gh/tafia/quick-xml/commit/7558577bc56e853db381910b4ab7a698cd0bbaba?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) to head [(`613a6bb`)](https://app.codecov.io/gh/tafia/quick-xml/commit/613a6bb0bab7ee5a8e0df1fafa05c6f469367a6b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> Report is 51 commits behind head on master.

| [Files](https://app.codecov.io/gh/tafia/quick-xml/pull/771?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Patch % | Lines |
|---|---|---|
| [src/escape.rs](https://app.codecov.io/gh/tafia/quick-xml/pull/771?src=pr&el=tree&filepath=src%2Fescape.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2VzY2FwZS5ycw==) | 63.15% | [7 Missing :warning: ](https://app.codecov.io/gh/tafia/quick-xml/pull/771?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) |
| [src/events/attributes.rs](https://app.codecov.io/gh/tafia/quick-xml/pull/771?src=pr&el=tree&filepath=src%2Fevents%2Fattributes.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2V2ZW50cy9hdHRyaWJ1dGVzLnJz) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/tafia/quick-xml/pull/771?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) |

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #771      +/-   ##
==========================================
- Coverage   61.81%   60.14%   -1.68%     
==========================================
  Files          41       41              
  Lines       16798    16115     -683     
==========================================
- Hits        10384     9692     -692     
- Misses       6414     6423       +9     
```

| [Flag](https://app.codecov.io/gh/tafia/quick-xml/pull/771/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/tafia/quick-xml/pull/771/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | `60.14% <65.51%> (-1.68%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/tafia/quick-xml/pull/771?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).


Patch:
diff --git a/Changelog.md b/Changelog.md
index 2d9f59a..cbcb7fe 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -22,9 +22,24 @@
 - [#773]: Fixed reporting incorrect end position in `Reader::read_to_end` family
   of methods and trimming of the trailing spaces in `Reader::read_text` when
   `trim_text_start` is set and the last event is not a `Text` event.
+- [#771]: Character references now allow any number of leading zeroes as it should.
+  As a result, the following variants of `quick_xml::escape::EscapeError` are removed:
+  - `TooLongDecimal`
+  - `TooLongHexadecimal`
+- [#771]: Fixed `Attribute::unescape_value` which does not unescape predefined values since 0.32.0.
 
 ### Misc Changes
 
+- [#771]: `EscapeError::UnrecognizedSymbol` renamed to `EscapeError::UnrecognizedEntity`.
+- [#771]: Implemented `PartialEq` for `EscapeError`.
+- [#771]: Replace the following variants of `EscapeError` by `InvalidCharRef` variant
+  with a new `ParseCharRefError` inside:
+  - `EntityWithNull`
+  - `InvalidDecimal`
+  - `InvalidHexadecimal`
+  - `InvalidCodepoint`
+
+[#771]: https://github.com/tafia/quick-xml/pull/771
 [#772]: https://github.com/tafia/quick-xml/pull/772
 [#773]: https://github.com/tafia/quick-xml/pull/773
 
diff --git a/src/de/mod.rs b/src/de/mod.rs
index 74461b8..e150e78 100644
--- a/src/de/mod.rs
+++ b/src/de/mod.rs
@@ -2135,9 +2135,9 @@ struct XmlReader<'i, R: XmlRead<'i>, E: EntityResolver = PredefinedEntityResolve
     lookahead: Result<PayloadEvent<'i>, DeError>,
 
     /// Used to resolve unknown entities that would otherwise cause the parser
-    /// to return an [`EscapeError::UnrecognizedSymbol`] error.
+    /// to return an [`EscapeError::UnrecognizedEntity`] error.
     ///
-    /// [`EscapeError::UnrecognizedSymbol`]: crate::escape::EscapeError::UnrecognizedSymbol
+    /// [`EscapeError::UnrecognizedEntity`]: crate::escape::EscapeError::UnrecognizedEntity
     entity_resolver: E,
 }
 
diff --git a/src/de/resolver.rs b/src/de/resolver.rs
index e53f2ed..5efc011 100644
--- a/src/de/resolver.rs
+++ b/src/de/resolver.rs
@@ -81,10 +81,10 @@ pub trait EntityResolver {
     /// Called when an entity needs to be resolved.
     ///
     /// `None` is returned if a suitable value can not be found.
-    /// In that case an [`EscapeError::UnrecognizedSymbol`] will be returned by
+    /// In that case an [`EscapeError::UnrecognizedEntity`] will be returned by
     /// a deserializer.
     ///
-    /// [`EscapeError::UnrecognizedSymbol`]: crate::escape::EscapeError::UnrecognizedSymbol
+    /// [`EscapeError::UnrecognizedEntity`]: crate::escape::EscapeError::UnrecognizedEntity
     fn resolve(&self, entity: &str) -> Option<&str>;
 }
 
diff --git a/src/encoding.rs b/src/encoding.rs
index e18e207..120d793 100644
--- a/src/encoding.rs
+++ b/src/encoding.rs
@@ -99,6 +99,15 @@ impl Decoder {
 
         Ok(())
     }
+
+    /// Decodes the `Cow` buffer, preserves the lifetime
+    pub(crate) fn decode_cow<'b>(&self, bytes: &Cow<'b, [u8]>) -> Result<Cow<'b, str>> {
+        match bytes {
+            Cow::Borrowed(bytes) => self.decode(bytes),
+            // Convert to owned, because otherwise Cow will be bound with wrong lifetime
+            Cow::Owned(bytes) => Ok(self.decode(bytes)?.into_owned().into()),
+        }
+    }
 }
 
 /// Decodes the provided bytes using the specified encoding.
diff --git a/src/escape.rs b/src/escape.rs
index 0cb042c..76c7f7e 100644
--- a/src/escape.rs
+++ b/src/escape.rs
@@ -2,62 +2,82 @@
 
 use memchr::memchr2_iter;
 use std::borrow::Cow;
+use std::num::ParseIntError;
 use std::ops::Range;
 
-#[cfg(test)]
-use pretty_assertions::assert_eq;
+/// Error of parsing character reference (`&#<dec-number>;` or `&#x<hex-number>;`).
+#[derive(Clone, Debug, PartialEq)]
+pub enum ParseCharRefError {
+    /// Number contains sign character (`+` or `-`) which is not allowed.
+    UnexpectedSign,
+    /// Number cannot be parsed due to non-number characters or a numeric overflow.
+    InvalidNumber(ParseIntError),
+    /// Character reference represents not a valid unicode codepoint.
+    InvalidCodepoint(u32),
+    /// Character reference expanded to a not permitted character for an XML.
+    ///
+    /// Currently, only `0x0` character produces this error.
+    IllegalCharacter(u32),
+}
+
+impl std::fmt::Display for ParseCharRefError {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match self {
+            Self::UnexpectedSign => f.write_str("unexpected number sign"),
+            Self::InvalidNumber(e) => e.fmt(f),
+            Self::InvalidCodepoint(n) => write!(f, "`{}` is not a valid codepoint", n),
+            Self::IllegalCharacter(n) => write!(f, "0x{:x} character is not permitted in XML", n),
+        }
+    }
+}
+
+impl std::error::Error for ParseCharRefError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Self::InvalidNumber(e) => Some(e),
+            _ => None,
+        }
+    }
+}
 
 /// Error for XML escape / unescape.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
 pub enum EscapeError {
-    /// Entity with Null character
-    EntityWithNull(Range<usize>),
-    /// Unrecognized escape symbol
-    UnrecognizedSymbol(Range<usize>, String),
+    /// Referenced entity in unknown to the parser.
+    UnrecognizedEntity(Range<usize>, String),
     /// Cannot find `;` after `&`
     UnterminatedEntity(Range<usize>),
-    /// Cannot convert Hexa to utf8
-    TooLongHexadecimal,
-    /// Character is not a valid hexadecimal value
-    InvalidHexadecimal(char),
-    /// Cannot convert decimal to hexa
-    TooLongDecimal,
-    /// Character is not a valid decimal value
-    InvalidDecimal(char),
-    /// Not a valid unicode codepoint
-    InvalidCodepoint(u32),
+    /// Attempt to parse character reference (`&#<dec-number>;` or `&#x<hex-number>;`)
+    /// was unsuccessful, not all characters are decimal or hexadecimal numbers.
+    InvalidCharRef(ParseCharRefError),
 }
 
 impl std::fmt::Display for EscapeError {
     fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
         match self {
-            EscapeError::EntityWithNull(e) => write!(
-                f,
-                "Error while escaping character at range {:?}: Null character entity not allowed",
-                e
-            ),
-            EscapeError::UnrecognizedSymbol(rge, res) => write!(
-                f,
-                "Error while escaping character at range {:?}: Unrecognized escape symbol: {:?}",
-                rge, res
-            ),
+            EscapeError::UnrecognizedEntity(rge, res) => {
+                write!(f, "at {:?}: unrecognized entity `{}`", rge, res)
+            }
             EscapeError::UnterminatedEntity(e) => write!(
                 f,
                 "Error while escaping character at range {:?}: Cannot find ';' after '&'",
                 e
             ),
-            EscapeError::TooLongHexadecimal => write!(f, "Cannot convert hexadecimal to utf8"),
-            EscapeError::InvalidHexadecimal(e) => {
-                write!(f, "'{}' is not a valid hexadecimal character", e)
+            EscapeError::InvalidCharRef(e) => {
+                write!(f, "invalid character reference: {}", e)
             }
-            EscapeError::TooLongDecimal => write!(f, "Cannot convert decimal to utf8"),
-            EscapeError::InvalidDecimal(e) => write!(f, "'{}' is not a valid decimal character", e),
-            EscapeError::InvalidCodepoint(n) => write!(f, "'{}' is not a valid codepoint", n),
         }
     }
 }
 
-impl std::error::Error for EscapeError {}
+impl std::error::Error for EscapeError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Self::InvalidCharRef(e) => Some(e),
+            _ => None,
+        }
+    }
+}
 
 /// Escapes an `&str` and replaces all xml special characters (`<`, `>`, `&`, `'`, `"`)
 /// with their corresponding xml escaped value.
@@ -251,12 +271,12 @@ where
                 // search for character correctness
                 let pat = &raw[start + 1..end];
                 if let Some(entity) = pat.strip_prefix('#') {
-                    let codepoint = parse_number(entity, start..end)?;
+                    let codepoint = parse_number(entity).map_err(EscapeError::InvalidCharRef)?;
                     unescaped.push_str(codepoint.encode_utf8(&mut [0u8; 4]));
                 } else if let Some(value) = resolve_entity(pat) {
                     unescaped.push_str(value);
                 } else {
-                    return Err(EscapeError::UnrecognizedSymbol(
+                    return Err(EscapeError::UnrecognizedEntity(
                         start + 1..end,
                         pat.to_string(),
                     ));
@@ -1796,141 +1816,27 @@ pub const fn resolve_html5_entity(entity: &str) -> Option<&'static str> {
     Some(s)
 }
 
-fn parse_number(bytes: &str, range: Range<usize>) -> Result<char, EscapeError> {
-    let code = if let Some(hex_digits) = bytes.strip_prefix('x') {
-        parse_hexadecimal(hex_digits)
+fn parse_number(num: &str) -> Result<char, ParseCharRefError> {
+    let code = if let Some(hex) = num.strip_prefix('x') {
+        from_str_radix(hex, 16)?
     } else {
-        parse_decimal(bytes)
-    }?;
+        from_str_radix(num, 10)?
+    };
     if code == 0 {
-        return Err(EscapeError::EntityWithNull(range));
+        return Err(ParseCharRefError::IllegalCharacter(code));
     }
     match std::char::from_u32(code) {
         Some(c) => Ok(c),
-        None => Err(EscapeError::InvalidCodepoint(code)),
-    }
-}
-
-fn parse_hexadecimal(bytes: &str) -> Result<u32, EscapeError> {
-    // maximum code is 0x10FFFF => 6 characters
-    if bytes.len() > 6 {
-        return Err(EscapeError::TooLongHexadecimal);
-    }
-    let mut code = 0;
-    for b in bytes.bytes() {
-        code <<= 4;
-        code += match b {
-            b'0'..=b'9' => b - b'0',
-            b'a'..=b'f' => b - b'a' + 10,
-            b'A'..=b'F' => b - b'A' + 10,
-            b => return Err(EscapeError::InvalidHexadecimal(b as char)),
-        } as u32;
+        None => Err(ParseCharRefError::InvalidCodepoint(code)),
     }
-    Ok(code)
 }
 
-fn parse_decimal(bytes: &str) -> Result<u32, EscapeError> {
-    // maximum code is 0x10FFFF = 1114111 => 7 characters
-    if bytes.len() > 7 {
-        return Err(EscapeError::TooLongDecimal);
-    }
-    let mut code = 0;
-    for b in bytes.bytes() {
-        code *= 10;
-        code += match b {
-            b'0'..=b'9' => b - b'0',
-            b => return Err(EscapeError::InvalidDecimal(b as char)),
-        } as u32;
+#[inline]
+fn from_str_radix(src: &str, radix: u32) -> Result<u32, ParseCharRefError> {
+    match src.as_bytes().first().copied() {
+        // We should not allow sign numbers, but u32::from_str_radix will accept `+`.
+        // We also handle `-` to be consistent in returned errors
+        Some(b'+') | Some(b'-') => Err(ParseCharRefError::UnexpectedSign),
+        _ => u32::from_str_radix(src, radix).map_err(ParseCharRefError::InvalidNumber),
     }
-    Ok(code)
-}
-
-#[test]
-fn test_unescape() {
-    let unchanged = unescape("test").unwrap();
-    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
-    // because it influences diff
-    // TODO: use assert_matches! when stabilized and other features will bump MSRV
-    assert_eq!(unchanged, Cow::Borrowed("test"));
-    assert!(matches!(unchanged, Cow::Borrowed(_)));
-
-    assert_eq!(
-        unescape("&lt;&amp;test&apos;&quot;&gt;").unwrap(),
-        "<&test'\">"
-    );
-    assert_eq!(unescape("&#x30;").unwrap(), "0");
-    assert_eq!(unescape("&#48;").unwrap(), "0");
-    assert!(unescape("&foo;").is_err());
-}
-
-#[test]
-fn test_unescape_with() {
-    let custom_entities = |ent: &str| match ent {
-        "foo" => Some("BAR"),
-        _ => None,
-    };
-
-    let unchanged = unescape_with("test", custom_entities).unwrap();
-    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
-    // because it influences diff
-    // TODO: use assert_matches! when stabilized and other features will bump MSRV
-    assert_eq!(unchanged, Cow::Borrowed("test"));
-    assert!(matches!(unchanged, Cow::Borrowed(_)));
-
-    assert!(unescape_with("&lt;", custom_entities).is_err());
-    assert_eq!(unescape_with("&#x30;", custom_entities).unwrap(), "0");
-    assert_eq!(unescape_with("&#48;", custom_entities).unwrap(), "0");
-    assert_eq!(unescape_with("&foo;", custom_entities).unwrap(), "BAR");
-    assert!(unescape_with("&fop;", custom_entities).is_err());
-}
-
-#[test]
-fn test_escape() {
-    let unchanged = escape("test");
-    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
-    // because it influences diff
-    // TODO: use assert_matches! when stabilized and other features will bump MSRV
-    assert_eq!(unchanged, Cow::Borrowed("test"));
-    assert!(matches!(unchanged, Cow::Borrowed(_)));
-
-    assert_eq!(escape("<&\"'>"), "&lt;&amp;&quot;&apos;&gt;");
-    assert_eq!(escape("<test>"), "&lt;test&gt;");
-    assert_eq!(escape("\"a\"bc"), "&quot;a&quot;bc");
-    assert_eq!(escape("\"a\"b&c"), "&quot;a&quot;b&amp;c");
-    assert_eq!(
-        escape("prefix_\"a\"b&<>c"),
-        "prefix_&quot;a&quot;b&amp;&lt;&gt;c"
-    );
-}
-
-#[test]
-fn test_partial_escape() {
-    let unchanged = partial_escape("test");
-    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
-    // because it influences diff
-    // TODO: use assert_matches! when stabilized and other features will bump MSRV
-    assert_eq!(unchanged, Cow::Borrowed("test"));
-    assert!(matches!(unchanged, Cow::Borrowed(_)));
-
-    assert_eq!(partial_escape("<&\"'>"), "&lt;&amp;\"'&gt;");
-    assert_eq!(partial_escape("<test>"), "&lt;test&gt;");
-    assert_eq!(partial_escape("\"a\"bc"), "\"a\"bc");
-    assert_eq!(partial_escape("\"a\"b&c"), "\"a\"b&amp;c");
-    assert_eq!(
-        partial_escape("prefix_\"a\"b&<>c"),
-        "prefix_\"a\"b&amp;&lt;&gt;c"
-    );
-}
-
-#[test]
-fn test_minimal_escape() {
-    assert_eq!(minimal_escape("test"), Cow::Borrowed("test"));
-    assert_eq!(minimal_escape("<&\"'>"), "&lt;&amp;\"'>");
-    assert_eq!(minimal_escape("<test>"), "&lt;test>");
-    assert_eq!(minimal_escape("\"a\"bc"), "\"a\"bc");
-    assert_eq!(minimal_escape("\"a\"b&c"), "\"a\"b&amp;c");
-    assert_eq!(
-        minimal_escape("prefix_\"a\"b&<>c"),
-        "prefix_\"a\"b&amp;&lt;>c"
-    );
 }
diff --git a/src/events/attributes.rs b/src/events/attributes.rs
index 5f56c04..4a2a34a 100644
--- a/src/events/attributes.rs
+++ b/src/events/attributes.rs
@@ -45,7 +45,7 @@ impl<'a> Attribute<'a> {
     /// [`encoding`]: ../../index.html#encoding
     #[cfg(any(doc, not(feature = "encoding")))]
     pub fn unescape_value(&self) -> XmlResult<Cow<'a, str>> {
-        self.unescape_value_with(|_| None)
+        self.unescape_value_with(resolve_predefined_entity)
     }
 
     /// Decodes using UTF-8 then unescapes the value, using custom entities.
@@ -63,22 +63,12 @@ impl<'a> Attribute<'a> {
     ///
     /// [`encoding`]: ../../index.html#encoding
     #[cfg(any(doc, not(feature = "encoding")))]
+    #[inline]
     pub fn unescape_value_with<'entity>(
         &self,
         resolve_entity: impl FnMut(&str) -> Option<&'entity str>,
     ) -> XmlResult<Cow<'a, str>> {
-        // from_utf8 should never fail because content is always UTF-8 encoded
-        let decoded = match &self.value {
-            Cow::Borrowed(bytes) => Cow::Borrowed(std::str::from_utf8(bytes)?),
-            // Convert to owned, because otherwise Cow will be bound with wrong lifetime
-            Cow::Owned(bytes) => Cow::Owned(std::str::from_utf8(bytes)?.to_string()),
-        };
-
-        match unescape_with(&decoded, resolve_entity)? {
-            // Because result is borrowed, no replacements was done and we can use original string
-            Cow::Borrowed(_) => Ok(decoded),
-            Cow::Owned(s) => Ok(s.into()),
-        }
+        self.decode_and_unescape_value_with(Decoder::utf8(), resolve_entity)
     }
 
     /// Decodes then unescapes the value.
@@ -98,11 +88,7 @@ impl<'a> Attribute<'a> {
         decoder: Decoder,
         resolve_entity: impl FnMut(&str) -> Option<&'entity str>,
     ) -> XmlResult<Cow<'a, str>> {
-        let decoded = match &self.value {
-            Cow::Borrowed(bytes) => decoder.decode(bytes)?,
-            // Convert to owned, because otherwise Cow will be bound with wrong lifetime
-            Cow::Owned(bytes) => decoder.decode(bytes)?.into_owned().into(),
-        };
+        let decoded = decoder.decode_cow(&self.value)?;
 
         match unescape_with(&decoded, resolve_entity)? {
             // Because result is borrowed, no replacements was done and we can use original string
diff --git a/src/events/mod.rs b/src/events/mod.rs
index 79df7e0..c9acb5b 100644
--- a/src/events/mod.rs
+++ b/src/events/mod.rs
@@ -595,11 +595,7 @@ impl<'a> BytesText<'a> {
         &self,
         resolve_entity: impl FnMut(&str) -> Option<&'entity str>,
     ) -> Result<Cow<'a, str>> {
-        let decoded = match &self.content {
-            Cow::Borrowed(bytes) => self.decoder.decode(bytes)?,
-            // Convert to owned, because otherwise Cow will be bound with wrong lifetime
-            Cow::Owned(bytes) => self.decoder.decode(bytes)?.into_owned().into(),
-        };
+        let decoded = self.decoder.decode_cow(&self.content)?;
 
         match unescape_with(&decoded, resolve_entity)? {
             // Because result is borrowed, no replacements was done and we can use original string
@@ -810,11 +806,7 @@ impl<'a> BytesCData<'a> {
 
     /// Gets content of this text buffer in the specified encoding
     pub(crate) fn decode(&self) -> Result<Cow<'a, str>> {
-        Ok(match &self.content {
-            Cow::Borrowed(bytes) => self.decoder.decode(bytes)?,
-            // Convert to owned, because otherwise Cow will be bound with wrong lifetime
-            Cow::Owned(bytes) => self.decoder.decode(bytes)?.into_owned().into(),
-        })
+        self.decoder.decode_cow(&self.content)
     }
 }
 


Test patch:
diff --git a/tests/escape.rs b/tests/escape.rs
new file mode 100644
index 0000000..894c97c
--- /dev/null
+++ b/tests/escape.rs
@@ -0,0 +1,244 @@
+use pretty_assertions::assert_eq;
+use quick_xml::escape::{self, EscapeError, ParseCharRefError};
+use std::borrow::Cow;
+use std::num::IntErrorKind;
+
+#[test]
+fn escape() {
+    let unchanged = escape::escape("test");
+    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
+    // because it influences diff
+    // TODO: use assert_matches! when stabilized and other features will bump MSRV
+    assert_eq!(unchanged, Cow::Borrowed("test"));
+    assert!(matches!(unchanged, Cow::Borrowed(_)));
+
+    assert_eq!(escape::escape("<&\"'>"), "&lt;&amp;&quot;&apos;&gt;");
+    assert_eq!(escape::escape("<test>"), "&lt;test&gt;");
+    assert_eq!(escape::escape("\"a\"bc"), "&quot;a&quot;bc");
+    assert_eq!(escape::escape("\"a\"b&c"), "&quot;a&quot;b&amp;c");
+    assert_eq!(
+        escape::escape("prefix_\"a\"b&<>c"),
+        "prefix_&quot;a&quot;b&amp;&lt;&gt;c"
+    );
+}
+
+#[test]
+fn partial_escape() {
+    let unchanged = escape::partial_escape("test");
+    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
+    // because it influences diff
+    // TODO: use assert_matches! when stabilized and other features will bump MSRV
+    assert_eq!(unchanged, Cow::Borrowed("test"));
+    assert!(matches!(unchanged, Cow::Borrowed(_)));
+
+    assert_eq!(escape::partial_escape("<&\"'>"), "&lt;&amp;\"'&gt;");
+    assert_eq!(escape::partial_escape("<test>"), "&lt;test&gt;");
+    assert_eq!(escape::partial_escape("\"a\"bc"), "\"a\"bc");
+    assert_eq!(escape::partial_escape("\"a\"b&c"), "\"a\"b&amp;c");
+    assert_eq!(
+        escape::partial_escape("prefix_\"a\"b&<>c"),
+        "prefix_\"a\"b&amp;&lt;&gt;c"
+    );
+}
+
+#[test]
+fn minimal_escape() {
+    assert_eq!(escape::minimal_escape("test"), Cow::Borrowed("test"));
+    assert_eq!(escape::minimal_escape("<&\"'>"), "&lt;&amp;\"'>");
+    assert_eq!(escape::minimal_escape("<test>"), "&lt;test>");
+    assert_eq!(escape::minimal_escape("\"a\"bc"), "\"a\"bc");
+    assert_eq!(escape::minimal_escape("\"a\"b&c"), "\"a\"b&amp;c");
+    assert_eq!(
+        escape::minimal_escape("prefix_\"a\"b&<>c"),
+        "prefix_\"a\"b&amp;&lt;>c"
+    );
+}
+
+#[test]
+fn unescape() {
+    let unchanged = escape::unescape("test");
+    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
+    // because it influences diff
+    // TODO: use assert_matches! when stabilized and other features will bump MSRV
+    assert_eq!(unchanged, Ok(Cow::Borrowed("test")));
+    assert!(matches!(unchanged, Ok(Cow::Borrowed(_))));
+
+    assert_eq!(
+        escape::unescape("&lt;&amp;test&apos;&quot;&gt;"),
+        Ok("<&test'\">".into())
+    );
+    assert_eq!(escape::unescape("&#x30;"), Ok("0".into()));
+    assert_eq!(escape::unescape("&#48;"), Ok("0".into()));
+    assert_eq!(
+        escape::unescape("&foo;"),
+        Err(EscapeError::UnrecognizedEntity(1..4, "foo".into()))
+    );
+}
+
+/// XML allows any number of leading zeroes. That is not explicitly mentioned
+/// in the specification, but enforced by the conformance test suite
+/// (https://www.w3.org/XML/Test/)
+/// 100 digits should be enough to ensure that any artificial restrictions
+/// (such as maximal string of u128 representation) does not applied
+#[test]
+fn unescape_long() {
+    assert_eq!(
+        escape::unescape("&#0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048;"),
+        Ok("0".into()),
+    );
+    assert_eq!(
+        escape::unescape("&#x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030;"),
+        Ok("0".into()),
+    );
+
+    // Too big numbers for u32 should produce errors
+    match escape::unescape(&format!("&#{};", u32::MAX as u64 + 1)) {
+        Err(EscapeError::InvalidCharRef(ParseCharRefError::InvalidNumber(err))) => {
+            assert_eq!(err.kind(), &IntErrorKind::PosOverflow)
+        }
+        x => panic!(
+            "expected Err(InvalidCharRef(InvalidNumber(PosOverflow))), bug got {:?}",
+            x
+        ),
+    }
+    match escape::unescape(&format!("&#x{:x};", u32::MAX as u64 + 1)) {
+        Err(EscapeError::InvalidCharRef(ParseCharRefError::InvalidNumber(err))) => {
+            assert_eq!(err.kind(), &IntErrorKind::PosOverflow)
+        }
+        x => panic!(
+            "expected Err(InvalidCharRef(InvalidNumber(PosOverflow))), bug got {:?}",
+            x
+        ),
+    }
+}
+
+#[test]
+fn unescape_sign() {
+    assert_eq!(
+        escape::unescape("&#+48;"),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+    assert_eq!(
+        escape::unescape("&#x+30;"),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+
+    assert_eq!(
+        escape::unescape("&#-48;"),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+    assert_eq!(
+        escape::unescape("&#x-30;"),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+}
+
+#[test]
+fn unescape_with() {
+    let custom_entities = |ent: &str| match ent {
+        "foo" => Some("BAR"),
+        _ => None,
+    };
+
+    let unchanged = escape::unescape_with("test", custom_entities);
+    // assert_eq does not check that Cow is borrowed, but we explicitly use Cow
+    // because it influences diff
+    // TODO: use assert_matches! when stabilized and other features will bump MSRV
+    assert_eq!(unchanged, Ok(Cow::Borrowed("test")));
+    assert!(matches!(unchanged, Ok(Cow::Borrowed(_))));
+
+    assert_eq!(
+        escape::unescape_with("&lt;", custom_entities),
+        Err(EscapeError::UnrecognizedEntity(1..3, "lt".into())),
+    );
+    assert_eq!(
+        escape::unescape_with("&#x30;", custom_entities),
+        Ok("0".into())
+    );
+    assert_eq!(
+        escape::unescape_with("&#48;", custom_entities),
+        Ok("0".into())
+    );
+    assert_eq!(
+        escape::unescape_with("&foo;", custom_entities),
+        Ok("BAR".into())
+    );
+    assert_eq!(
+        escape::unescape_with("&fop;", custom_entities),
+        Err(EscapeError::UnrecognizedEntity(1..4, "fop".into()))
+    );
+}
+
+/// XML allows any number of leading zeroes. That is not explicitly mentioned
+/// in the specification, but enforced by the conformance test suite
+/// (https://www.w3.org/XML/Test/)
+/// 100 digits should be enough to ensure that any artificial restrictions
+/// (such as maximal string of u128 representation) does not applied
+#[test]
+fn unescape_with_long() {
+    assert_eq!(
+        escape::unescape_with("&#0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048;", |_| None),
+        Ok("0".into()),
+    );
+    assert_eq!(
+        escape::unescape_with("&#x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030;", |_| None),
+        Ok("0".into()),
+    );
+
+    // Too big numbers for u32 should produce errors
+    match escape::unescape_with(&format!("&#{};", u32::MAX as u64 + 1), |_| None) {
+        Err(EscapeError::InvalidCharRef(ParseCharRefError::InvalidNumber(err))) => {
+            assert_eq!(err.kind(), &IntErrorKind::PosOverflow)
+        }
+        x => panic!(
+            "expected Err(InvalidCharRef(InvalidNumber(PosOverflow))), bug got {:?}",
+            x
+        ),
+    }
+    match escape::unescape_with(&format!("&#x{:x};", u32::MAX as u64 + 1), |_| None) {
+        Err(EscapeError::InvalidCharRef(ParseCharRefError::InvalidNumber(err))) => {
+            assert_eq!(err.kind(), &IntErrorKind::PosOverflow)
+        }
+        x => panic!(
+            "expected Err(InvalidCharRef(InvalidNumber(PosOverflow))), bug got {:?}",
+            x
+        ),
+    }
+}
+
+#[test]
+fn unescape_with_sign() {
+    assert_eq!(
+        escape::unescape_with("&#+48;", |_| None),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+    assert_eq!(
+        escape::unescape_with("&#x+30;", |_| None),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+
+    assert_eq!(
+        escape::unescape_with("&#-48;", |_| None),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+    assert_eq!(
+        escape::unescape_with("&#x-30;", |_| None),
+        Err(EscapeError::InvalidCharRef(
+            ParseCharRefError::UnexpectedSign
+        )),
+    );
+}