Skip to main content

xml_sec/xml_input/
shared.rs

1//! Backend-neutral XML byte encoding detection and strict transcoding.
2//!
3//! Disable the default `std` feature for an alloc-only decoder and lexical scanner. The
4//! `std::io::Write`-based lexical writer is available only when `std` is enabled.
5
6use alloc::{borrow::Cow, string::String};
7use core::ops::Range;
8
9#[path = "lexical.rs"]
10pub mod lexical;
11
12/// Failure while converting external bytes into the Unicode XML parser contract.
13#[derive(Debug, thiserror::Error)]
14#[non_exhaustive]
15pub enum Error {
16    /// The byte signature selected an encoding that this implementation cannot decode.
17    #[error("unsupported XML byte encoding `{0}`")]
18    UnsupportedByteEncoding(&'static str),
19    /// An encoding label was not recognized.
20    #[error("unsupported XML encoding `{0}`")]
21    UnsupportedEncoding(String),
22    /// Resolver metadata, a byte signature, and the XML declaration disagreed.
23    #[error("XML byte encoding conflicts with declared or selected encoding `{0}`")]
24    ConflictingEncoding(String),
25    /// The XML declaration was malformed before parsing could begin.
26    #[error("malformed XML encoding declaration: {0}")]
27    MalformedDeclaration(&'static str),
28    /// The selected decoder rejected malformed input instead of replacing it.
29    #[error("XML input contains invalid {0} bytes")]
30    InvalidBytes(&'static str),
31    /// A BOM-less UTF-16 document did not identify its byte order.
32    #[error("BOM-less UTF-16 XML input requires an explicit UTF-16LE or UTF-16BE declaration")]
33    MissingUtf16ByteOrder,
34    /// A UTF-32 document did not identify its byte order through metadata or its signature.
35    #[error("UTF-32 XML input requires a UTF-32LE/UTF-32BE encoding or byte-order signature")]
36    MissingUtf32ByteOrder,
37    /// A non-UTF-8/UTF-16 entity omitted both external encoding metadata and its declaration.
38    #[error("{0} XML input requires an encoding declaration or trusted external encoding metadata")]
39    MissingEncodingDeclaration(&'static str),
40    /// A UTF-16 code unit was truncated.
41    #[error("{0} XML input has an odd byte length")]
42    InvalidUtf16Length(&'static str),
43    /// A UTF-32 code unit was truncated.
44    #[error("{0} XML input byte length is not divisible by four")]
45    InvalidUtf32Length(&'static str),
46    /// Decoded UTF-8 would exceed the caller's materialization ceiling.
47    #[error("decoded XML exceeds the maximum size of {maximum} bytes: at least {actual} bytes")]
48    DecodedLimit {
49        /// Configured maximum decoded size.
50        maximum: usize,
51        /// Minimum decoded size required by the input.
52        actual: usize,
53    },
54}
55
56#[derive(Clone, Copy, PartialEq, Eq)]
57enum SelectedEncoding {
58    Standard(&'static encoding_rs::Encoding),
59    Utf32Le,
60    Utf32Be,
61    Ascii,
62    Registered(IanaSingleByteEncoding),
63}
64
65impl SelectedEncoding {
66    fn name(self) -> &'static str {
67        match self {
68            Self::Standard(encoding) => encoding.name(),
69            Self::Utf32Le => "UTF-32LE",
70            Self::Utf32Be => "UTF-32BE",
71            Self::Ascii => "US-ASCII",
72            Self::Registered(encoding) => encoding.name(),
73        }
74    }
75
76    fn is_utf8(self) -> bool {
77        matches!(self, Self::Standard(encoding) if encoding == encoding_rs::UTF_8)
78    }
79}
80
81/// Strict IANA single-byte repertoire shared by XML input and XSLT output.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum IanaSingleByteEncoding {
84    /// ISO-8859-1 (Latin-1).
85    Latin1,
86    /// ISO-8859-9 (Latin-5).
87    Latin5,
88    /// ISO-8859-11 Thai encoding.
89    Iso8859_11,
90    /// TIS-620 Thai encoding.
91    Tis620,
92}
93
94impl IanaSingleByteEncoding {
95    #[must_use]
96    /// Return the registered encoding name.
97    pub const fn name(self) -> &'static str {
98        match self {
99            Self::Latin1 => "ISO-8859-1",
100            Self::Latin5 => "ISO-8859-9",
101            Self::Iso8859_11 => "ISO-8859-11",
102            Self::Tis620 => "TIS-620",
103        }
104    }
105
106    #[must_use]
107    /// Decode one byte, or return `None` for an undefined mapping.
108    pub fn decode_byte(self, byte: u8) -> Option<char> {
109        match self {
110            Self::Latin1 => Some(char::from(byte)),
111            Self::Latin5 => Some(match byte {
112                0xD0 => '\u{011E}',
113                0xDD => '\u{0130}',
114                0xDE => '\u{015E}',
115                0xF0 => '\u{011F}',
116                0xFD => '\u{0131}',
117                0xFE => '\u{015F}',
118                _ => char::from(byte),
119            }),
120            Self::Iso8859_11 | Self::Tis620 => match byte {
121                0x00..=0x7F => Some(char::from(byte)),
122                0xA0 if self == Self::Iso8859_11 => Some('\u{00A0}'),
123                0xA1..=0xDA | 0xE0..=0xFB => char::from_u32(u32::from(byte) + 0x0D60),
124                0xDF => Some('\u{0E3F}'),
125                _ => None,
126            },
127        }
128    }
129
130    #[must_use]
131    /// Encode one character, or return `None` if it is not representable.
132    pub fn encode_char(self, character: char) -> Option<u8> {
133        match self {
134            Self::Latin1 => u8::try_from(u32::from(character)).ok(),
135            Self::Latin5 => match character {
136                '\u{011E}' => Some(0xD0),
137                '\u{0130}' => Some(0xDD),
138                '\u{015E}' => Some(0xDE),
139                '\u{011F}' => Some(0xF0),
140                '\u{0131}' => Some(0xFD),
141                '\u{015F}' => Some(0xFE),
142                _ => u8::try_from(u32::from(character))
143                    .ok()
144                    .filter(|byte| !matches!(byte, 0xD0 | 0xDD | 0xDE | 0xF0 | 0xFD | 0xFE)),
145            },
146            Self::Iso8859_11 | Self::Tis620 => match u32::from(character) {
147                value @ 0x00..=0x7F => Some(value as u8),
148                0xA0 if self == Self::Iso8859_11 => Some(0xA0),
149                value @ 0x0E01..=0x0E3A | value @ 0x0E40..=0x0E5B => {
150                    u8::try_from(value - 0x0D60).ok()
151                }
152                0x0E3F => Some(0xDF),
153                _ => None,
154            },
155        }
156    }
157}
158
159/// Resolve labels whose IANA meaning differs from WHATWG-compatible decoders.
160#[must_use]
161pub fn registered_single_byte_encoding(label: &str) -> Option<IanaSingleByteEncoding> {
162    if is_latin1_encoding_label(label) {
163        return Some(IanaSingleByteEncoding::Latin1);
164    }
165    if matches_ascii_case(
166        label,
167        &[
168            "iso-ir-148",
169            "iso88599",
170            "iso-8859-9",
171            "iso_8859-9",
172            "latin5",
173            "csisolatin5",
174            "iso_8859-9:1989",
175        ],
176    ) {
177        return Some(IanaSingleByteEncoding::Latin5);
178    }
179    if matches_ascii_case(label, &["iso8859-11", "iso-8859-11"]) {
180        return Some(IanaSingleByteEncoding::Iso8859_11);
181    }
182    label
183        .eq_ignore_ascii_case("tis-620")
184        .then_some(IanaSingleByteEncoding::Tis620)
185}
186
187/// Return whether a WHATWG label lookup preserves the caller's requested legacy encoding.
188///
189/// WHATWG redirects many ISO labels to Windows code pages. Callers that promise exact IANA
190/// semantics must either implement those repertoires explicitly or reject the redirected label.
191#[must_use]
192pub fn legacy_label_matches_encoding(
193    label: &str,
194    encoding: &'static encoding_rs::Encoding,
195) -> bool {
196    let canonical = encoding.name();
197    let Some(code_page) = canonical.strip_prefix("windows-") else {
198        return true;
199    };
200    label.eq_ignore_ascii_case(canonical)
201        || label
202            .get(2..)
203            .is_some_and(|suffix| label[..2].eq_ignore_ascii_case("cp") && suffix == code_page)
204        || label
205            .get(4..)
206            .is_some_and(|suffix| label[..4].eq_ignore_ascii_case("x-cp") && suffix == code_page)
207}
208
209/// Decode XML bytes according to XML 1.0 encoding detection rules.
210///
211/// `explicit_encoding` is trusted resolver metadata. It is checked against the
212/// byte signature and XML declaration rather than silently overriding either.
213/// UTF-8 input is borrowed when no declaration rewrite is required; other
214/// encodings are strictly transcoded and their declaration is normalized.
215pub fn decode_xml<'a>(
216    bytes: &'a [u8],
217    explicit_encoding: Option<&str>,
218) -> Result<Cow<'a, str>, Error> {
219    decode_xml_bounded(bytes, explicit_encoding, usize::MAX)
220}
221
222/// Decode XML while preventing either the transcoded or normalized UTF-8
223/// representation from growing beyond `maximum_decoded_bytes`.
224pub fn decode_xml_bounded<'a>(
225    bytes: &'a [u8],
226    explicit_encoding: Option<&str>,
227    maximum_decoded_bytes: usize,
228) -> Result<Cow<'a, str>, Error> {
229    decode_xml_bounded_inner(bytes, explicit_encoding, maximum_decoded_bytes, true)
230}
231
232/// Detect XML-media-type text encoding without changing the included text's declaration.
233///
234/// XInclude `parse="text"` uses XML encoding detection for XML media types, but includes the
235/// decoded characters as text rather than reparsing or rewriting an XML declaration.
236pub fn decode_xml_text_bounded<'a>(
237    bytes: &'a [u8],
238    explicit_encoding: Option<&str>,
239    maximum_decoded_bytes: usize,
240) -> Result<Cow<'a, str>, Error> {
241    decode_xml_bounded_inner(bytes, explicit_encoding, maximum_decoded_bytes, false)
242}
243
244fn decode_xml_bounded_inner<'a>(
245    bytes: &'a [u8],
246    explicit_encoding: Option<&str>,
247    maximum_decoded_bytes: usize,
248    normalize_declaration: bool,
249) -> Result<Cow<'a, str>, Error> {
250    let physical = physical_encoding(bytes)?;
251    let ascii_declaration = if physical.is_none() {
252        declaration_from_ascii_bytes(bytes)?
253    } else {
254        None
255    };
256    let explicit_utf16 = explicit_encoding.is_some_and(is_generic_utf16);
257    let explicit_utf32 = explicit_encoding.is_some_and(is_generic_utf32);
258    let explicit = explicit_encoding
259        .filter(|_| !explicit_utf16 && !explicit_utf32)
260        .map(parse_encoding)
261        .transpose()?;
262    if explicit_utf16
263        && !physical.is_some_and(|(encoding, bom_len)| is_utf16_encoding(encoding) && bom_len > 0)
264    {
265        // XML 1.0 section 4.3.3 requires an entity labeled as generic UTF-16 to begin with a BOM;
266        // a declaration discovered after decoding cannot replace that byte-order signature.
267        // https://www.w3.org/TR/xml/#charencoding
268        return Err(Error::MissingUtf16ByteOrder);
269    }
270    if explicit_utf32 && !physical.is_some_and(|(encoding, _)| is_utf32_encoding(encoding)) {
271        return Err(Error::MissingUtf32ByteOrder);
272    }
273    let declared_before_decode = ascii_declaration
274        .as_ref()
275        .map(|(_, label)| parse_encoding(label))
276        .transpose()?;
277    let selected = explicit
278        .or(physical.map(|(encoding, _)| encoding))
279        .or(declared_before_decode)
280        .unwrap_or(SelectedEncoding::Standard(encoding_rs::UTF_8));
281
282    if let Some((physical, _)) = physical
283        && !encodings_compatible(selected, physical, true)
284    {
285        return Err(Error::ConflictingEncoding(selected.name().into()));
286    }
287    if let Some(declared) = declared_before_decode
288        && !encodings_compatible(selected, declared, false)
289    {
290        return Err(Error::ConflictingEncoding(declared.name().into()));
291    }
292
293    let bom_len = physical.map_or(0, |(_, bom_len)| bom_len);
294    let mut decoded = decode_selected(&bytes[bom_len..], selected, maximum_decoded_bytes)?;
295    let declaration = declaration_from_text(&decoded)?;
296    if explicit_encoding.is_none()
297        && declaration.is_none()
298        && physical.is_some_and(|(encoding, bom_len)| {
299            is_utf32_encoding(encoding) || (bom_len == 0 && is_utf16_encoding(encoding))
300        })
301    {
302        // XML 1.0 section 4.3.3 permits declarationless entities only for UTF-8 and UTF-16. A
303        // UTF-32 BOM identifies byte order but does not make UTF-32 one of those two exceptions.
304        // https://www.w3.org/TR/xml/#charencoding
305        return Err(
306            if physical.is_some_and(|(encoding, _)| is_utf32_encoding(encoding)) {
307                Error::MissingEncodingDeclaration("UTF-32")
308            } else {
309                Error::MissingUtf16ByteOrder
310            },
311        );
312    }
313    if let Some(range) = &declaration {
314        let label = &decoded[range.clone()];
315        if is_generic_utf16(label) {
316            let has_utf16_bom = physical.is_some_and(|(encoding, bom_len)| {
317                bom_len > 0
318                    && matches!(encoding, SelectedEncoding::Standard(value)
319                        if value == encoding_rs::UTF_16LE || value == encoding_rs::UTF_16BE)
320            });
321            if !has_utf16_bom {
322                return Err(Error::MissingUtf16ByteOrder);
323            }
324        } else if is_generic_utf32(label) {
325            if !physical.is_some_and(|(encoding, _)| is_utf32_encoding(encoding)) {
326                return Err(Error::MissingUtf32ByteOrder);
327            }
328        } else {
329            let declared = parse_encoding(label)?;
330            if !encodings_compatible(selected, declared, false) {
331                return Err(Error::ConflictingEncoding(label.into()));
332            }
333        }
334    }
335
336    if normalize_declaration
337        && !selected.is_utf8()
338        && let Some(range) = declaration
339    {
340        let normalized_len = decoded
341            .len()
342            .saturating_sub(range.len())
343            .saturating_add("UTF-8".len());
344        if normalized_len > maximum_decoded_bytes {
345            return Err(Error::DecodedLimit {
346                maximum: maximum_decoded_bytes,
347                actual: normalized_len,
348            });
349        }
350        decoded.to_mut().replace_range(range, "UTF-8");
351    }
352    Ok(decoded)
353}
354
355/// Decode a non-XML text resource using an explicit character encoding.
356pub fn decode_text<'a>(bytes: &'a [u8], encoding: &str) -> Result<Cow<'a, str>, Error> {
357    decode_text_bounded(bytes, encoding, usize::MAX)
358}
359
360/// Decode a non-XML text resource under a retained-byte ceiling.
361pub fn decode_text_bounded<'a>(
362    bytes: &'a [u8],
363    encoding: &str,
364    maximum_decoded_bytes: usize,
365) -> Result<Cow<'a, str>, Error> {
366    if is_generic_utf16(encoding) {
367        // RFC 2781 section 3.3 requires a byte-order signature when the generic UTF-16
368        // label is used. Consume that signature before exposing text to the caller.
369        // https://www.rfc-editor.org/rfc/rfc2781.html#section-3.3
370        if let Some(payload) = bytes.strip_prefix(&[0xFF, 0xFE]) {
371            return decode_selected(
372                payload,
373                SelectedEncoding::Standard(encoding_rs::UTF_16LE),
374                maximum_decoded_bytes,
375            );
376        }
377        if let Some(payload) = bytes.strip_prefix(&[0xFE, 0xFF]) {
378            return decode_selected(
379                payload,
380                SelectedEncoding::Standard(encoding_rs::UTF_16BE),
381                maximum_decoded_bytes,
382            );
383        }
384        return Err(Error::MissingUtf16ByteOrder);
385    }
386    let selected = parse_encoding(encoding)?;
387    let bytes = match selected {
388        // XInclude 1.0 section 4.3 retains an initial U+FEFF for explicit-endian text;
389        // only generic UTF-16 treats it as a BOM. The opposite-order signature is invalid.
390        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
391        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_16LE => {
392            if bytes.starts_with(&[0xFE, 0xFF]) {
393                return Err(Error::ConflictingEncoding(selected.name().into()));
394            }
395            bytes
396        }
397        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_16BE => {
398            if bytes.starts_with(&[0xFF, 0xFE]) {
399                return Err(Error::ConflictingEncoding(selected.name().into()));
400            }
401            bytes
402        }
403        // XInclude 1.0 section 4.3 uses this signature to identify UTF-8, not as included text.
404        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
405        SelectedEncoding::Standard(value) if value == encoding_rs::UTF_8 => {
406            bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes)
407        }
408        _ => bytes,
409    };
410    decode_selected(bytes, selected, maximum_decoded_bytes)
411}
412
413fn physical_encoding(bytes: &[u8]) -> Result<Option<(SelectedEncoding, usize)>, Error> {
414    let prefix = bytes.get(..4).unwrap_or(bytes);
415    // XML 1.0 Appendix F defines UCS-4 BOMs and initial `<` signatures. The two
416    // unusual octet orders are recognized but intentionally unsupported.
417    // https://www.w3.org/TR/xml/#sec-guessing
418    match prefix {
419        [0x00, 0x00, 0xFE, 0xFF] => return Ok(Some((SelectedEncoding::Utf32Be, 4))),
420        [0xFF, 0xFE, 0x00, 0x00] => return Ok(Some((SelectedEncoding::Utf32Le, 4))),
421        [0x00, 0x00, 0x00, b'<'] => return Ok(Some((SelectedEncoding::Utf32Be, 0))),
422        [b'<', 0x00, 0x00, 0x00] => return Ok(Some((SelectedEncoding::Utf32Le, 0))),
423        [0x00, 0x00, 0xFF, 0xFE]
424        | [0xFE, 0xFF, 0x00, 0x00]
425        | [0x00, 0x00, b'<', 0x00]
426        | [0x00, b'<', 0x00, 0x00] => {
427            return Err(Error::UnsupportedByteEncoding("UTF-32 unusual byte order"));
428        }
429        _ => {}
430    }
431    if prefix == [0x4C, 0x6F, 0xA7, 0x94] {
432        return Err(Error::UnsupportedByteEncoding("EBCDIC"));
433    }
434    if let Some((encoding, length)) = encoding_rs::Encoding::for_bom(bytes) {
435        return Ok(Some((SelectedEncoding::Standard(encoding), length)));
436    }
437    Ok(match prefix {
438        [0x00, b'<', 0x00, b'?'] => Some((SelectedEncoding::Standard(encoding_rs::UTF_16BE), 0)),
439        [b'<', 0x00, b'?', 0x00] => Some((SelectedEncoding::Standard(encoding_rs::UTF_16LE), 0)),
440        _ => None,
441    })
442}
443
444fn parse_encoding(label: &str) -> Result<SelectedEncoding, Error> {
445    if matches_ascii_case(label, &["utf-32le", "utf32le"]) {
446        return Ok(SelectedEncoding::Utf32Le);
447    }
448    if matches_ascii_case(label, &["utf-32be", "utf32be"]) {
449        return Ok(SelectedEncoding::Utf32Be);
450    }
451    if matches_ascii_case(label, &["us-ascii", "ascii"]) {
452        return Ok(SelectedEncoding::Ascii);
453    }
454    // The IANA-registered labels below name the same ISO-8859-1 repertoire;
455    // WHATWG-style lookup would incorrectly map them to Windows-1252. `latin-1`
456    // is retained as the already-supported punctuation variant.
457    // https://www.iana.org/assignments/character-sets/character-sets.xhtml
458    if let Some(encoding) = registered_single_byte_encoding(label) {
459        return Ok(SelectedEncoding::Registered(encoding));
460    }
461    // `encoding_rs` exposes some registered ISO repertoires directly (including
462    // ISO-8859-2). Reject only lookups whose canonical result proves that the
463    // requested IANA label was redirected to a Windows extension with different C1 bytes.
464    // XML 1.0 section 4.3.3 requires registered labels to retain their IANA meaning.
465    // https://www.w3.org/TR/xml/#charencoding
466    // XInclude 1.0 sections 4.2-4.3 make an unsupported text encoding a resource error.
467    // Decoder-only WHATWG labels must therefore not select the replacement decoder.
468    // https://www.w3.org/TR/xinclude/#text_included
469    let encoding = encoding_rs::Encoding::for_label_no_replacement(label.as_bytes())
470        .ok_or_else(|| Error::UnsupportedEncoding(label.into()))?;
471    if !legacy_label_matches_encoding(label, encoding) {
472        return Err(Error::UnsupportedEncoding(label.into()));
473    }
474    Ok(SelectedEncoding::Standard(encoding))
475}
476
477/// Return whether `label` selects strict ISO-8859-1 semantics.
478///
479/// This intentionally does not use WHATWG label matching, which maps these
480/// XML encoding names to Windows-1252 instead of the registered repertoire.
481#[must_use]
482pub fn is_latin1_encoding_label(label: &str) -> bool {
483    matches_ascii_case(
484        label,
485        &[
486            "iso_8859-1:1987",
487            "iso-ir-100",
488            "iso_8859-1",
489            "iso-8859-1",
490            "latin1",
491            "latin-1",
492            "l1",
493            "ibm819",
494            "cp819",
495            "csisolatin1",
496        ],
497    )
498}
499
500fn decode_selected<'a>(
501    bytes: &'a [u8],
502    encoding: SelectedEncoding,
503    maximum: usize,
504) -> Result<Cow<'a, str>, Error> {
505    if matches!(
506        encoding,
507        SelectedEncoding::Utf32Le | SelectedEncoding::Utf32Be
508    ) {
509        return decode_utf32(bytes, encoding, maximum).map(Cow::Owned);
510    }
511    if encoding == SelectedEncoding::Ascii {
512        if bytes.iter().any(|byte| !byte.is_ascii()) {
513            return Err(Error::InvalidBytes("US-ASCII"));
514        }
515        let decoded =
516            core::str::from_utf8(bytes).expect("seven-bit US-ASCII is always valid UTF-8");
517        if decoded.len() > maximum {
518            return Err(Error::DecodedLimit {
519                maximum,
520                actual: decoded.len(),
521            });
522        }
523        return Ok(Cow::Borrowed(decoded));
524    }
525    if matches!(encoding, SelectedEncoding::Registered(_)) {
526        return decode_registered_single_byte(bytes, encoding, maximum).map(Cow::Owned);
527    }
528    let SelectedEncoding::Standard(encoding) = encoding else {
529        unreachable!("special-case encodings returned above")
530    };
531    if encoding == encoding_rs::UTF_8 {
532        let decoded = core::str::from_utf8(bytes).map_err(|_| Error::InvalidBytes("UTF-8"))?;
533        if decoded.len() > maximum {
534            return Err(Error::DecodedLimit {
535                maximum,
536                actual: decoded.len(),
537            });
538        }
539        return Ok(Cow::Borrowed(decoded));
540    }
541    if (encoding == encoding_rs::UTF_16LE || encoding == encoding_rs::UTF_16BE)
542        && !bytes.len().is_multiple_of(2)
543    {
544        return Err(Error::InvalidUtf16Length(encoding.name()));
545    }
546
547    let mut decoder = encoding.new_decoder_without_bom_handling();
548    let mut remaining = bytes;
549    let mut decoded = String::with_capacity(bytes.len().min(maximum));
550    let mut buffer = [0_u8; 4096];
551    loop {
552        let (result, read, written) =
553            decoder.decode_to_utf8_without_replacement(remaining, &mut buffer, true);
554        let actual = decoded.len().saturating_add(written);
555        if actual > maximum {
556            return Err(Error::DecodedLimit { maximum, actual });
557        }
558        decoded.push_str(
559            core::str::from_utf8(&buffer[..written])
560                .expect("encoding_rs emits valid UTF-8 into the output buffer"),
561        );
562        remaining = &remaining[read..];
563        match result {
564            encoding_rs::DecoderResult::InputEmpty => return Ok(Cow::Owned(decoded)),
565            encoding_rs::DecoderResult::OutputFull => {}
566            encoding_rs::DecoderResult::Malformed(_, _) => {
567                return Err(Error::InvalidBytes(encoding.name()));
568            }
569        }
570    }
571}
572
573fn decode_registered_single_byte(
574    bytes: &[u8],
575    encoding: SelectedEncoding,
576    maximum: usize,
577) -> Result<String, Error> {
578    let mut decoded = String::with_capacity(bytes.len().min(maximum));
579    for &byte in bytes {
580        let character = match encoding {
581            SelectedEncoding::Registered(encoding) => encoding
582                .decode_byte(byte)
583                .ok_or_else(|| Error::InvalidBytes(encoding.name()))?,
584            _ => unreachable!("registered single-byte decoder receives a matching encoding"),
585        };
586        let actual = decoded.len().saturating_add(character.len_utf8());
587        if actual > maximum {
588            return Err(Error::DecodedLimit { maximum, actual });
589        }
590        decoded.push(character);
591    }
592    Ok(decoded)
593}
594
595fn decode_utf32(bytes: &[u8], encoding: SelectedEncoding, maximum: usize) -> Result<String, Error> {
596    if !bytes.len().is_multiple_of(4) {
597        return Err(Error::InvalidUtf32Length(encoding.name()));
598    }
599    let mut decoded = String::with_capacity(bytes.len().min(maximum));
600    let (units, remainder) = bytes.as_chunks::<4>();
601    debug_assert!(remainder.is_empty());
602    for &unit in units {
603        let scalar = match encoding {
604            SelectedEncoding::Utf32Le => u32::from_le_bytes(unit),
605            SelectedEncoding::Utf32Be => u32::from_be_bytes(unit),
606            _ => unreachable!("UTF-32 decoder receives an explicit byte order"),
607        };
608        let character = char::from_u32(scalar).ok_or(Error::InvalidBytes(encoding.name()))?;
609        let actual = decoded.len().saturating_add(character.len_utf8());
610        if actual > maximum {
611            return Err(Error::DecodedLimit { maximum, actual });
612        }
613        decoded.push(character);
614    }
615    Ok(decoded)
616}
617
618fn encodings_compatible(
619    selected: SelectedEncoding,
620    candidate: SelectedEncoding,
621    physical: bool,
622) -> bool {
623    selected == candidate
624        || (physical
625            && matches!(selected, SelectedEncoding::Standard(value) if value == encoding_rs::UTF_8)
626            && matches!(candidate, SelectedEncoding::Standard(value) if value == encoding_rs::UTF_8))
627}
628
629fn is_utf16_encoding(encoding: SelectedEncoding) -> bool {
630    matches!(encoding, SelectedEncoding::Standard(value)
631        if value == encoding_rs::UTF_16LE || value == encoding_rs::UTF_16BE)
632}
633
634fn is_utf32_encoding(encoding: SelectedEncoding) -> bool {
635    matches!(
636        encoding,
637        SelectedEncoding::Utf32Le | SelectedEncoding::Utf32Be
638    )
639}
640
641// XML 1.0 section 2.3 production [3] defines S as exactly these four bytes.
642// https://www.w3.org/TR/xml/#NT-S
643const fn is_xml_s_byte(byte: &u8) -> bool {
644    matches!(*byte, b' ' | b'\t' | b'\r' | b'\n')
645}
646
647fn declaration_from_ascii_bytes(bytes: &[u8]) -> Result<Option<(Range<usize>, &str)>, Error> {
648    let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
649    if !bytes.starts_with(b"<?xml") || !bytes.get(5).is_some_and(is_xml_s_byte) {
650        return Ok(None);
651    }
652    let end = bytes
653        .windows(2)
654        .position(|window| window == b"?>")
655        .map(|index| index + 2)
656        .ok_or(Error::MalformedDeclaration("unterminated declaration"))?;
657    // XML encoding declarations are ASCII for every supported
658    // ASCII-compatible encoding, regardless of the following document bytes.
659    let prefix = core::str::from_utf8(&bytes[..end])
660        .map_err(|_| Error::MalformedDeclaration("declaration is not ASCII-compatible"))?;
661    declaration_from_text(prefix).map(|range| {
662        range.map(|range| {
663            let label = &prefix[range.clone()];
664            (range, label)
665        })
666    })
667}
668
669fn declaration_from_text(xml: &str) -> Result<Option<Range<usize>>, Error> {
670    let Some(rest) = xml.strip_prefix("<?xml") else {
671        return Ok(None);
672    };
673    if !rest.as_bytes().first().is_some_and(is_xml_s_byte) {
674        return Ok(None);
675    }
676    let end = rest
677        .find("?>")
678        .ok_or(Error::MalformedDeclaration("unterminated declaration"))?;
679    let declaration = &rest.as_bytes()[..end];
680    let mut cursor = 0;
681    let mut encoding_range = None;
682    while cursor < declaration.len() {
683        while declaration.get(cursor).is_some_and(is_xml_s_byte) {
684            cursor += 1;
685        }
686        if cursor == declaration.len() {
687            break;
688        }
689        let name_start = cursor;
690        while declaration.get(cursor).is_some_and(|byte| {
691            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b':' | b'-' | b'.')
692        }) {
693            cursor += 1;
694        }
695        if cursor == name_start {
696            return Err(Error::MalformedDeclaration("invalid pseudo-attribute"));
697        }
698        let name = &declaration[name_start..cursor];
699        while declaration.get(cursor).is_some_and(is_xml_s_byte) {
700            cursor += 1;
701        }
702        if declaration.get(cursor) != Some(&b'=') {
703            return Err(Error::MalformedDeclaration("missing `=`"));
704        }
705        cursor += 1;
706        while declaration.get(cursor).is_some_and(is_xml_s_byte) {
707            cursor += 1;
708        }
709        let &quote @ (b'\'' | b'"') = declaration
710            .get(cursor)
711            .ok_or(Error::MalformedDeclaration("missing quoted value"))?
712        else {
713            return Err(Error::MalformedDeclaration("value is not quoted"));
714        };
715        let value_start = cursor + 1;
716        let value_end = value_start
717            + declaration[value_start..]
718                .iter()
719                .position(|byte| *byte == quote)
720                .ok_or(Error::MalformedDeclaration("unterminated value"))?;
721        if name == b"encoding" {
722            let value = &declaration[value_start..value_end];
723            // XML 1.0 section 4.3.3 production [81] requires EncName to start with an ASCII
724            // letter and limits the remaining characters. Validate before normalization erases
725            // the declaration. https://www.w3.org/TR/xml/#NT-EncName
726            if !is_xml_encoding_name_bytes(value) {
727                return Err(Error::MalformedDeclaration("invalid encoding name"));
728            }
729            encoding_range = Some((5 + value_start)..(5 + value_end));
730        }
731        cursor = value_end + 1;
732        if declaration
733            .get(cursor)
734            .is_some_and(|byte| !is_xml_s_byte(byte))
735        {
736            return Err(Error::MalformedDeclaration("missing whitespace"));
737        }
738    }
739    // XML 1.0 section 2.8 production [23] makes EncodingDecl part of one complete XMLDecl;
740    // selection is valid only after every following pseudo-attribute has been checked.
741    // https://www.w3.org/TR/xml/#NT-XMLDecl
742    Ok(encoding_range)
743}
744
745/// Return whether a label satisfies XML 1.0's `EncName` production.
746///
747/// See XML 1.0 section 4.3.3, production [81]:
748/// https://www.w3.org/TR/xml/#NT-EncName
749#[must_use]
750pub fn is_xml_encoding_name(value: &str) -> bool {
751    is_xml_encoding_name_bytes(value.as_bytes())
752}
753
754fn is_xml_encoding_name_bytes(value: &[u8]) -> bool {
755    value.first().is_some_and(u8::is_ascii_alphabetic)
756        && value[1..]
757            .iter()
758            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
759}
760
761fn is_generic_utf16(label: &str) -> bool {
762    label.eq_ignore_ascii_case("UTF-16") || label.eq_ignore_ascii_case("UTF16")
763}
764
765fn is_generic_utf32(label: &str) -> bool {
766    label.eq_ignore_ascii_case("UTF-32") || label.eq_ignore_ascii_case("UTF32")
767}
768
769fn matches_ascii_case(value: &str, candidates: &[&str]) -> bool {
770    candidates
771        .iter()
772        .any(|candidate| value.eq_ignore_ascii_case(candidate))
773}
774
775#[cfg(all(test, feature = "std"))]
776#[allow(clippy::unwrap_used)]
777mod tests {
778    use std::borrow::Cow;
779
780    use super::{
781        Error, declaration_from_ascii_bytes, declaration_from_text, decode_text, decode_xml,
782        decode_xml_bounded,
783    };
784
785    fn encode_utf32(source: &str, little_endian: bool) -> Vec<u8> {
786        source
787            .chars()
788            .flat_map(|character| {
789                let value = u32::from(character);
790                if little_endian {
791                    value.to_le_bytes()
792                } else {
793                    value.to_be_bytes()
794                }
795            })
796            .collect()
797    }
798
799    #[test]
800    fn utf8_without_a_rewritten_declaration_stays_borrowed() {
801        let bytes = b"<root>ok</root>";
802        assert!(matches!(decode_xml(bytes, None), Ok(Cow::Borrowed(_))));
803    }
804
805    #[test]
806    fn generic_utf16_uses_the_bom_byte_order() {
807        let source = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root>lambda</root>";
808        let mut bytes = vec![0xFE, 0xFF];
809        bytes.extend(source.encode_utf16().flat_map(u16::to_be_bytes));
810        let decoded = decode_xml(&bytes, Some("UTF-16")).expect("BOM selects UTF-16BE");
811        assert!(decoded.contains("encoding=\"UTF-8\""));
812        assert!(decoded.contains("<root>lambda</root>"));
813    }
814
815    #[test]
816    fn xml_declaration_rejects_values_outside_the_enc_name_grammar() {
817        // XML 1.0 section 4.3.3 production [81] excludes `:` from EncName.
818        // https://www.w3.org/TR/xml/#NT-EncName
819        let bytes = b"<?xml version=\"1.0\" encoding=\"iso_8859-1:1987\"?><root/>";
820        assert!(matches!(
821            decode_xml(bytes, None),
822            Err(Error::MalformedDeclaration("invalid encoding name"))
823        ));
824    }
825
826    #[test]
827    fn encoding_selection_requires_a_complete_well_formed_declaration() {
828        // XML 1.0 section 2.8 production [23] requires the declaration to match XMLDecl in full;
829        // finding EncodingDecl is not permission to ignore malformed trailing pseudo-attributes.
830        // https://www.w3.org/TR/xml/#NT-XMLDecl
831        let malformed = "<?xml version=\"1.0\" encoding=\"UTF-8\" trailing?><root/>";
832        assert!(matches!(
833            declaration_from_text(malformed),
834            Err(Error::MalformedDeclaration("missing `=`"))
835        ));
836        assert!(matches!(
837            declaration_from_ascii_bytes(malformed.as_bytes()),
838            Err(Error::MalformedDeclaration("missing `=`"))
839        ));
840
841        let mut utf16 = vec![0xFF, 0xFE];
842        utf16.extend(malformed.encode_utf16().flat_map(u16::to_le_bytes));
843        assert!(matches!(
844            decode_xml(&utf16, None),
845            Err(Error::MalformedDeclaration("missing `=`"))
846        ));
847    }
848
849    #[test]
850    fn xml_declaration_rejects_non_xml_ascii_whitespace() {
851        // XML 1.0 section 2.3 production [3] limits S to space, tab, CR, and LF.
852        // https://www.w3.org/TR/xml/#NT-S
853        for whitespace in *b"\x0b\x0c" {
854            let source = [
855                b"<?xml".as_slice(),
856                &[whitespace],
857                b"version=\"1.0\" encoding=\"ISO-8859-1\"?><root>caf\xe9</root>".as_slice(),
858            ]
859            .concat();
860            assert!(matches!(
861                decode_xml(&source, None),
862                Err(Error::InvalidBytes("UTF-8"))
863            ));
864        }
865    }
866
867    #[test]
868    fn xml_prefixed_processing_instruction_is_not_a_declaration() {
869        // XML 1.0 sections 2.6 and 2.8 distinguish PI targets from XMLDecl by the mandatory
870        // whitespace after `xml`: https://www.w3.org/TR/xml/#sec-prolog-dtd
871        let bytes = b"<?xml-stylesheet title='caf\xE9'?><root/>";
872        assert_eq!(
873            decode_xml(bytes, Some("windows-1252")).expect("PI follows resolver encoding"),
874            "<?xml-stylesheet title='caf\u{e9}'?><root/>"
875        );
876    }
877
878    #[test]
879    fn utf32_requires_a_bom_metadata_or_encoding_declaration() {
880        // XML 1.0 Appendix F defines both UCS-4 BOMs and the BOM-less `<` signatures.
881        // https://www.w3.org/TR/xml/#sec-guessing
882        let source = "<?xml version=\"1.0\" encoding=\"UTF-32\"?><root>lambda</root>";
883        for (little_endian, bom) in [
884            (false, [0x00, 0x00, 0xFE, 0xFF]),
885            (true, [0xFF, 0xFE, 0x00, 0x00]),
886        ] {
887            let mut bytes = bom.to_vec();
888            bytes.extend(encode_utf32(source, little_endian));
889            let decoded = decode_xml(&bytes, None).expect("UTF-32 BOM selects byte order");
890            assert!(decoded.contains("encoding=\"UTF-8\""));
891            assert!(decoded.contains("<root>lambda</root>"));
892
893            let mut declarationless = bom.to_vec();
894            declarationless.extend(encode_utf32("<root>lambda</root>", little_endian));
895            assert!(
896                decode_xml(&declarationless, None).is_err(),
897                "a UTF-32 BOM identifies byte order but does not replace the encoding declaration"
898            );
899        }
900
901        for little_endian in [false, true] {
902            let declaration = encode_utf32(
903                "<?xml version=\"1.0\" encoding=\"UTF-32\"?><root>lambda</root>",
904                little_endian,
905            );
906            assert!(
907                decode_xml(&declaration, None)
908                    .expect("the initial signature and declaration identify UTF-32")
909                    .contains("<root>lambda</root>")
910            );
911            let declarationless = encode_utf32("<root>lambda</root>", little_endian);
912            assert!(matches!(
913                decode_xml(&declarationless, None),
914                Err(Error::MissingEncodingDeclaration("UTF-32"))
915            ));
916            assert_eq!(
917                decode_xml(
918                    &declarationless,
919                    Some(if little_endian {
920                        "UTF-32LE"
921                    } else {
922                        "UTF-32BE"
923                    })
924                )
925                .expect("trusted external metadata supplies the encoding"),
926                "<root>lambda</root>"
927            );
928        }
929    }
930
931    #[test]
932    fn utf32_rejects_truncation_invalid_scalars_and_conflicting_metadata() {
933        let mut truncated = vec![0x00, 0x00, 0xFE, 0xFF];
934        truncated.extend([0x00, 0x00, 0x00]);
935        assert!(decode_xml(&truncated, None).is_err());
936
937        let mut surrogate = vec![0x00, 0x00, 0xFE, 0xFF];
938        surrogate.extend(0xD800_u32.to_be_bytes());
939        assert!(matches!(
940            decode_xml(&surrogate, None),
941            Err(Error::InvalidBytes("UTF-32BE"))
942        ));
943
944        let mut little_endian = vec![0xFF, 0xFE, 0x00, 0x00];
945        little_endian.extend(encode_utf32("<root/>", true));
946        assert!(matches!(
947            decode_xml(&little_endian, Some("UTF-32BE")),
948            Err(Error::ConflictingEncoding(_))
949        ));
950    }
951
952    #[test]
953    fn utf32_decoding_obeys_the_utf8_materialization_limit() {
954        let mut bytes = vec![0x00, 0x00, 0xFE, 0xFF];
955        bytes.extend(encode_utf32("<root>lambda</root>", false));
956        assert!(matches!(
957            decode_xml_bounded(&bytes, None, 8),
958            Err(Error::DecodedLimit { maximum: 8, .. })
959        ));
960    }
961
962    #[test]
963    fn bomless_generic_utf16_is_rejected_as_ambiguous() {
964        let bytes = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root/>"
965            .encode_utf16()
966            .flat_map(u16::to_le_bytes)
967            .collect::<Vec<_>>();
968        assert!(matches!(
969            decode_xml(&bytes, Some("UTF-16")),
970            Err(Error::MissingUtf16ByteOrder)
971        ));
972    }
973
974    #[test]
975    fn generic_utf16_metadata_does_not_replace_the_required_bom() {
976        // XML 1.0 section 4.3.3 requires every UTF-16 entity to begin with a BOM; a generic
977        // transport label supplies no byte order and cannot relax that document constraint.
978        // https://www.w3.org/TR/xml/#charencoding
979        let bytes = "<?xml version=\"1.0\"?><root/>"
980            .encode_utf16()
981            .flat_map(u16::to_le_bytes)
982            .collect::<Vec<_>>();
983        assert!(matches!(
984            decode_xml(&bytes, Some("UTF-16")),
985            Err(Error::MissingUtf16ByteOrder)
986        ));
987    }
988
989    #[test]
990    fn generic_utf16_metadata_cannot_bypass_the_bom_via_a_specific_declaration() {
991        // XML 1.0 section 4.3.3 requires generic UTF-16 entities to begin with a BOM even when
992        // their declaration reveals the byte order: https://www.w3.org/TR/xml/#charencoding
993        let bytes = "<?xml version=\"1.0\" encoding=\"UTF-16LE\"?><root/>"
994            .encode_utf16()
995            .flat_map(u16::to_le_bytes)
996            .collect::<Vec<_>>();
997        assert!(matches!(
998            decode_xml(&bytes, Some("UTF-16")),
999            Err(Error::MissingUtf16ByteOrder)
1000        ));
1001    }
1002
1003    #[test]
1004    fn explicit_utf16_byte_order_decodes_a_declarationless_resource() {
1005        let bytes = "<root>lambda</root>"
1006            .encode_utf16()
1007            .flat_map(u16::to_le_bytes)
1008            .collect::<Vec<_>>();
1009        assert_eq!(
1010            decode_xml(&bytes, Some("UTF-16LE")).unwrap(),
1011            "<root>lambda</root>"
1012        );
1013    }
1014
1015    #[test]
1016    fn endian_specific_utf16_accepts_a_matching_byte_order_mark() {
1017        // RFC 2781 sections 4.1 and 4.2 require a matching signature to be consumed without
1018        // affecting deserialization. https://www.rfc-editor.org/rfc/rfc2781#section-4.1
1019        let mut metadata = vec![0xFF, 0xFE];
1020        metadata.extend("<root/>".encode_utf16().flat_map(u16::to_le_bytes));
1021        assert_eq!(decode_xml(&metadata, Some("UTF-16LE")).unwrap(), "<root/>");
1022        assert_eq!(
1023            decode_text(&metadata, "UTF-16LE").unwrap(),
1024            "\u{feff}<root/>"
1025        );
1026
1027        let mut big_endian_text = vec![0xFE, 0xFF];
1028        big_endian_text.extend("body".encode_utf16().flat_map(u16::to_be_bytes));
1029        assert_eq!(
1030            decode_text(&big_endian_text, "UTF-16BE").unwrap(),
1031            "\u{feff}body"
1032        );
1033
1034        let mut declaration = vec![0xFE, 0xFF];
1035        declaration.extend(
1036            "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><root/>"
1037                .encode_utf16()
1038                .flat_map(u16::to_be_bytes),
1039        );
1040        assert_eq!(
1041            decode_xml(&declaration, None).unwrap(),
1042            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"
1043        );
1044    }
1045
1046    #[test]
1047    fn generic_utf16_text_requires_and_consumes_a_byte_order_mark() {
1048        let text = "lambda";
1049        let mut little = vec![0xFF, 0xFE];
1050        little.extend(text.encode_utf16().flat_map(u16::to_le_bytes));
1051        let mut big = vec![0xFE, 0xFF];
1052        big.extend(text.encode_utf16().flat_map(u16::to_be_bytes));
1053
1054        assert_eq!(decode_text(&little, "UTF-16").unwrap(), text);
1055        assert_eq!(decode_text(&big, "UTF-16").unwrap(), text);
1056        assert!(matches!(
1057            decode_text(&little[2..], "UTF-16"),
1058            Err(Error::MissingUtf16ByteOrder)
1059        ));
1060    }
1061
1062    #[test]
1063    fn utf8_text_consumes_its_signature_without_removing_content_fe_ff() {
1064        // XInclude 1.0 section 4.3 treats the BOM as an encoding signature, not included text.
1065        // https://www.w3.org/TR/2006/REC-xinclude-20061115/#text
1066        assert_eq!(decode_text(b"\xef\xbb\xbfbody", "UTF-8").unwrap(), "body");
1067        assert_eq!(decode_text(b"\xef\xbb\xbfbody", "utf8").unwrap(), "body");
1068        assert_eq!(
1069            decode_text(b"body\xef\xbb\xbftail", "UTF-8").unwrap(),
1070            "body\u{feff}tail"
1071        );
1072        assert_eq!(decode_text(b"body", "UTF-8").unwrap(), "body");
1073    }
1074
1075    #[test]
1076    fn trusted_metadata_cannot_conflict_with_a_bom() {
1077        assert!(matches!(
1078            decode_xml(&[0xFF, 0xFE, b'A', 0], Some("UTF-8")),
1079            Err(Error::ConflictingEncoding(_))
1080        ));
1081    }
1082
1083    #[test]
1084    fn latin1_and_windows_1252_keep_distinct_c1_semantics() {
1085        // Every IANA label must select the exact registered repertoire rather than the
1086        // WHATWG replacement decoder used for HTML compatibility.
1087        for alias in [
1088            "ISO_8859-1:1987",
1089            "iso-ir-100",
1090            "ISO_8859-1",
1091            "ISO-8859-1",
1092            "latin1",
1093            "l1",
1094            "IBM819",
1095            "CP819",
1096            "csISOLatin1",
1097        ] {
1098            assert_eq!(
1099                decode_text(&[0x80], alias).unwrap(),
1100                "\u{80}",
1101                "IANA alias {alias} must retain ISO-8859-1 C1 semantics"
1102            );
1103        }
1104        assert_eq!(decode_text(&[0x80], "windows-1252").unwrap(), "€");
1105    }
1106
1107    #[test]
1108    fn iana_single_byte_encodings_do_not_inherit_windows_extensions() {
1109        // XML 1.0 section 4.3.3 requires IANA labels to retain their registered meaning.
1110        // https://www.w3.org/TR/xml/#charencoding
1111        assert_eq!(decode_text(&[0x80], "ISO-8859-9").unwrap(), "\u{80}");
1112        assert_eq!(decode_text(&[0x80], "iso88599").unwrap(), "\u{80}");
1113        assert_eq!(decode_text(&[0xD0, 0xFD], "ISO-8859-9").unwrap(), "Ğı");
1114        assert_eq!(decode_text(&[0x80], "windows-1254").unwrap(), "€");
1115
1116        assert_eq!(decode_text(&[0xA0], "ISO-8859-11").unwrap(), "\u{A0}");
1117        assert!(matches!(
1118            decode_text(&[0x80], "TIS-620"),
1119            Err(Error::InvalidBytes("TIS-620"))
1120        ));
1121        assert!(matches!(
1122            decode_text(&[0xA0], "TIS-620"),
1123            Err(Error::InvalidBytes("TIS-620"))
1124        ));
1125        assert_eq!(decode_text(&[0xA1, 0xFB], "TIS-620").unwrap(), "ก๛");
1126        assert_eq!(decode_text(&[0x80], "windows-874").unwrap(), "€");
1127    }
1128
1129    #[test]
1130    fn registered_iana_labels_retain_their_declared_repertoires() {
1131        // XML 1.0 section 4.3.3 requires an IANA encoding name to retain its registered
1132        // semantics; ISO-8859-2 therefore must not be confused with Windows-1250.
1133        // https://www.w3.org/TR/xml/#charencoding
1134        assert_eq!(decode_text(&[0x80], "ISO-8859-2").unwrap(), "\u{80}");
1135        assert_eq!(decode_text(&[0xA1], "ISO-8859-2").unwrap(), "Ą");
1136        assert_eq!(decode_text(&[0x80], "windows-1250").unwrap(), "€");
1137
1138        let source = b"<?xml version=\"1.0\" encoding=\"ISO-8859-2\"?><root>\xA1</root>";
1139        assert!(decode_xml(source, None).unwrap().contains("<root>Ą</root>"));
1140    }
1141
1142    #[test]
1143    fn encoding_lookup_selects_the_strict_iso_8859_2_codec() {
1144        // Keep the dependency contract explicit: this label is not a WHATWG redirect in the
1145        // encoding_rs release used by the parser, so the standard decoder is the strict codec.
1146        assert_eq!(
1147            encoding_rs::Encoding::for_label(b"ISO-8859-2"),
1148            Some(encoding_rs::ISO_8859_2)
1149        );
1150        assert_eq!(encoding_rs::ISO_8859_2.name(), "ISO-8859-2");
1151    }
1152
1153    #[test]
1154    fn decoder_only_labels_are_reported_as_unsupported() {
1155        // XInclude 1.0 sections 4.2-4.3 classify an unsupported text encoding as a resource
1156        // error, so decoder-only WHATWG labels must not reach the replacement decoder.
1157        // https://www.w3.org/TR/xinclude/#text_included
1158        for label in ["replacement", "ISO-2022-KR"] {
1159            assert!(matches!(
1160                decode_text(b"", label),
1161                Err(Error::UnsupportedEncoding(rejected)) if rejected == label
1162            ));
1163        }
1164    }
1165
1166    #[test]
1167    fn us_ascii_rejects_non_ascii_bytes() {
1168        // WHATWG aliases US-ASCII to Windows-1252, but XML's declared encoding
1169        // contract permits only seven-bit bytes for this label.
1170        assert!(matches!(
1171            decode_text(&[0x80], "US-ASCII"),
1172            Err(Error::InvalidBytes("US-ASCII"))
1173        ));
1174        assert_eq!(
1175            decode_text(b"plain ASCII", "US-ASCII").unwrap(),
1176            "plain ASCII"
1177        );
1178    }
1179
1180    #[test]
1181    fn transcoded_and_normalized_representations_are_both_bounded() {
1182        let bytes = b"<?xml version=\"1.0\" encoding=\"GBK\"?><root/>";
1183        let exact = decode_xml(bytes, None).expect("GBK declaration is supported");
1184        assert!(matches!(
1185            decode_xml_bounded(bytes, None, exact.len() - 1),
1186            Err(Error::DecodedLimit { .. })
1187        ));
1188        assert_eq!(decode_xml_bounded(bytes, None, exact.len()).unwrap(), exact);
1189    }
1190
1191    #[test]
1192    fn declaration_detection_is_not_limited_to_a_short_prefix() {
1193        let whitespace = " ".repeat(2_048);
1194        let source = format!("<?xml{whitespace}encoding=\"ISO-8859-1\"?><root>caf\u{e9}</root>");
1195        let bytes = source
1196            .chars()
1197            .map(|character| u8::try_from(u32::from(character)).unwrap())
1198            .collect::<Vec<_>>();
1199        assert!(decode_xml(&bytes, None).unwrap().contains("café"));
1200    }
1201
1202    #[test]
1203    fn declaration_allows_whitespace_before_its_terminator() {
1204        let source = b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\" ?><root>caf\xe9</root>";
1205        assert!(decode_xml(source, None).unwrap().contains("café"));
1206    }
1207
1208    #[test]
1209    fn unsupported_xml_signatures_fail_explicitly() {
1210        assert!(matches!(
1211            decode_xml(&[0x4C, 0x6F, 0xA7, 0x94], None),
1212            Err(Error::UnsupportedByteEncoding("EBCDIC"))
1213        ));
1214    }
1215
1216    #[test]
1217    fn truncated_utf16_reports_the_code_unit_boundary() {
1218        assert!(matches!(
1219            decode_xml(&[0xff, 0xfe, 0], None),
1220            Err(Error::InvalidUtf16Length("UTF-16LE"))
1221        ));
1222    }
1223}