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