Skip to main content

oxixml_encoding/
sniff.rs

1//! XML entity encoding sniffing, per
2//! [XML 1.0 Appendix F: Autodetection of Character Encodings](https://www.w3.org/TR/xml/#sec-guessing).
3//!
4//! Appendix F describes a two-stage heuristic for figuring out how to start decoding an XML
5//! entity that has no external encoding information (no `Content-Type` charset parameter, for
6//! example):
7//!
8//! 1. Look at the entity's first few bytes. A byte-order mark, or one of a handful of
9//!    fixed 2-or-4-byte patterns that `<?xm` (the start of every XML declaration) transforms
10//!    into under different encodings, narrows things down to a coarse "encoding family" without
11//!    decoding anything yet — see [`ByteOrderGuess`].
12//! 2. If that family is ASCII-compatible (or UTF-16, whose ASCII range is trivially recovered),
13//!    decode *just enough* of the prologue to search for a `<?xml ... encoding="..." ?>`
14//!    pseudo-attribute and read the declared label out of it.
15//!
16//! [`sniff_xml_encoding`] performs both stages and returns everything discovered along the way
17//! as a [`SniffResult`]; policy decisions (e.g. "trust the declared label over the byte-order
18//! guess") are left to the caller, though [`SniffResult::best_effort_encoding`] implements the
19//! reasonable default.
20
21use crate::label::Encoding;
22
23/// The number of bytes (for single-byte-scannable families) or code units (for UTF-16 families,
24/// where the byte budget is doubled) of the entity's prologue that [`sniff_xml_encoding`] will
25/// examine when searching for an `encoding="..."` pseudo-attribute.
26///
27/// An XML declaration is required to be the very first thing in the entity and is normally well
28/// under 100 bytes, so this is generous headroom rather than a tight fit.
29const SCAN_LIMIT_BYTES: usize = 1024;
30
31/// The coarse encoding family implied purely by the first few bytes of an XML entity, before any
32/// attempt to read an `encoding="..."` declaration.
33///
34/// This mirrors the byte-pattern table of XML 1.0 Appendix F.1 in full, including the UTF-32 and
35/// EBCDIC patterns that this crate cannot itself decode ([`Encoding`] has no such variants) —
36/// recognizing the pattern is still useful so a caller can produce a precise diagnostic ("this
37/// looks like EBCDIC, which is unsupported") instead of a generic decode failure. Use
38/// [`ByteOrderGuess::supported_encoding`] to get the [`Encoding`] this crate can actually decode
39/// with, if any.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ByteOrderGuess {
42    /// `EF BB BF` (BOM) or `3C 3F 78 6D` (`<?xm` as ASCII/UTF-8/Latin-1/windows-1252/...).
43    Utf8,
44    /// `FF FE` (BOM) or `3C 00 3F 00` (`<?xm` as UTF-16, little-endian, no BOM).
45    Utf16Le,
46    /// `FE FF` (BOM) or `00 3C 00 3F` (`<?xm` as UTF-16, big-endian, no BOM).
47    Utf16Be,
48    /// `FF FE 00 00`: UTF-32, little-endian. Not decodable by this crate.
49    Utf32Le,
50    /// `00 00 FE FF`: UTF-32, big-endian. Not decodable by this crate.
51    Utf32Be,
52    /// `00 00 FF FE`: UTF-32 with an unusual 2-1-4-3 octet order. Not decodable by this crate.
53    Utf32Order2143,
54    /// `FE FF 00 00`: UTF-32 with an unusual 3-4-1-2 octet order. Not decodable by this crate.
55    Utf32Order3412,
56    /// `4C 6F A7 94` (`<?xm` in an EBCDIC code page). Not decodable by this crate.
57    Ebcdic,
58}
59
60impl ByteOrderGuess {
61    /// The [`Encoding`] this crate can actually decode with for this guess, if any.
62    ///
63    /// [`ByteOrderGuess::Utf8`] maps to [`Encoding::Utf8`] here as a *transport* guess only: at
64    /// this stage all that's known is that the entity is ASCII-compatible, not specifically that
65    /// it is UTF-8 rather than, say, windows-1252 — [`SniffResult::declared_encoding`] (read from
66    /// the prologue) is what actually distinguishes those.
67    #[must_use]
68    pub const fn supported_encoding(self) -> Option<Encoding> {
69        match self {
70            ByteOrderGuess::Utf8 => Some(Encoding::Utf8),
71            ByteOrderGuess::Utf16Le => Some(Encoding::Utf16Le),
72            ByteOrderGuess::Utf16Be => Some(Encoding::Utf16Be),
73            ByteOrderGuess::Utf32Le
74            | ByteOrderGuess::Utf32Be
75            | ByteOrderGuess::Utf32Order2143
76            | ByteOrderGuess::Utf32Order3412
77            | ByteOrderGuess::Ebcdic => None,
78        }
79    }
80}
81
82/// The result of [`sniff_xml_encoding`]: everything the Appendix F heuristic could determine
83/// about an entity's encoding from its leading bytes and (if reachable) its XML declaration.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SniffResult {
86    /// The coarse family implied by the entity's first bytes, or [`None`] if none of the
87    /// Appendix F patterns matched (which, per the spec, itself implies UTF-8 or an
88    /// ASCII-compatible encoding: see [`SniffResult::best_effort_encoding`]).
89    pub byte_order_guess: Option<ByteOrderGuess>,
90    /// The number of leading bytes identified as a byte-order mark; `0` if none was present.
91    /// These bytes are *not* included in [`declared_label`](SniffResult::declared_label)
92    /// scanning and should be skipped before decoding the entity itself.
93    pub bom_len: usize,
94    /// The raw label text captured from an `encoding="..."` (or `encoding='...'`) pseudo-attribute
95    /// in the XML declaration, if the prologue could be scanned and one was found.
96    pub declared_label: Option<String>,
97    /// [`declared_label`](SniffResult::declared_label) resolved through [`Encoding::for_label`],
98    /// if it was present and recognized.
99    pub declared_encoding: Option<Encoding>,
100}
101
102impl SniffResult {
103    /// A reasonable single [`Encoding`] to start decoding with, combining everything this
104    /// [`SniffResult`] discovered: the declared label wins if present and recognized, otherwise
105    /// the byte-order guess if it maps to a supported encoding, otherwise the XML 1.0 default of
106    /// UTF-8.
107    ///
108    /// # UTF-16
109    ///
110    /// One case is deliberately *not* decided by the declaration: a UTF-16 byte order. The label
111    /// `UTF-16` carries no endianness of its own (XML 1.0 Appendix F and RFC 2781 both say the
112    /// byte-order mark, or the Appendix F.1 four-byte pattern, is what determines it), while
113    /// WHATWG's label table — which [`Encoding::for_label`] implements — resolves the bare label
114    /// to [`Encoding::Utf16Le`] as an arbitrary default. Taking that default literally would
115    /// decode every big-endian `<?xml ... encoding="UTF-16"?>` document as little-endian
116    /// mojibake, even though the declaration could only be *read* in the first place by decoding
117    /// the prologue big-endian. So whenever the leading bytes say UTF-16 — a BOM, or the exact
118    /// `00 3C 00 3F` / `3C 00 3F 00` pattern that no ASCII-compatible encoding can produce —
119    /// that byte order wins outright over whatever the declaration claims. See
120    /// [`SniffResult::has_conflicting_declaration`] for detecting the contradictory case that
121    /// XML 1.0 §4.3.3 makes a fatal error.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use oxixml_encoding::{sniff_xml_encoding, Encoding};
127    ///
128    /// let bare = sniff_xml_encoding(b"<root/>");
129    /// assert_eq!(bare.best_effort_encoding(), Encoding::Utf8);
130    ///
131    /// let declared = sniff_xml_encoding(b"<?xml version=\"1.0\" encoding=\"windows-1252\"?><r/>");
132    /// assert_eq!(declared.best_effort_encoding(), Encoding::Windows1252);
133    ///
134    /// // A big-endian document whose declaration says only the endianness-free "UTF-16"
135    /// // still decodes big-endian.
136    /// let mut big_endian = vec![0xFE, 0xFF];
137    /// for unit in "<?xml version=\"1.0\" encoding=\"UTF-16\"?><r/>".encode_utf16() {
138    ///     big_endian.extend_from_slice(&unit.to_be_bytes());
139    /// }
140    /// assert_eq!(
141    ///     sniff_xml_encoding(&big_endian).best_effort_encoding(),
142    ///     Encoding::Utf16Be
143    /// );
144    /// ```
145    #[must_use]
146    pub fn best_effort_encoding(&self) -> Encoding {
147        let byte_order_encoding = self
148            .byte_order_guess
149            .and_then(ByteOrderGuess::supported_encoding);
150        // A UTF-16 byte order comes from a BOM or from the Appendix F.1 four-byte pattern, both
151        // of which are exact and neither of which any ASCII-compatible encoding can produce. It
152        // is therefore authoritative: the declaration -- which was only legible at all because
153        // the prologue was decoded as UTF-16 with this very byte order -- may confirm the family
154        // but never flip the byte order, let alone name another family.
155        if let Some(from_bytes @ (Encoding::Utf16Le | Encoding::Utf16Be)) = byte_order_encoding {
156            return from_bytes;
157        }
158        if let Some(encoding) = self.declared_encoding {
159            return encoding;
160        }
161        if let Some(encoding) = byte_order_encoding {
162            return encoding;
163        }
164        Encoding::Utf8
165    }
166
167    /// Whether the declared label contradicts the byte-order evidence in a way XML 1.0 §4.3.3
168    /// makes a fatal error ("it is a fatal error for an entity ... to be presented to the XML
169    /// processor in an encoding other than that named in the declaration").
170    ///
171    /// This is `true` only when both signals are present, both are decodable by this crate, and
172    /// they disagree — including the endianness-only disagreement that
173    /// [`best_effort_encoding`](SniffResult::best_effort_encoding) silently resolves in favour of
174    /// the bytes, e.g. a big-endian entity declaring `encoding="UTF-16LE"`. An entity carrying no
175    /// declaration, or one whose declaration merely refines the coarse ASCII-compatible guess
176    /// (`windows-1252`, `Shift_JIS`, ...), is not a conflict.
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use oxixml_encoding::sniff_xml_encoding;
182    ///
183    /// // A big-endian BOM with a matching-family, endianness-free label: no conflict.
184    /// let mut agreeing = vec![0xFE, 0xFF];
185    /// for unit in "<?xml version=\"1.0\" encoding=\"UTF-16\"?><r/>".encode_utf16() {
186    ///     agreeing.extend_from_slice(&unit.to_be_bytes());
187    /// }
188    /// assert!(!sniff_xml_encoding(&agreeing).has_conflicting_declaration());
189    ///
190    /// // The same bytes explicitly declaring the *other* byte order: a conflict.
191    /// let mut disagreeing = vec![0xFE, 0xFF];
192    /// for unit in "<?xml version=\"1.0\" encoding=\"UTF-16LE\"?><r/>".encode_utf16() {
193    ///     disagreeing.extend_from_slice(&unit.to_be_bytes());
194    /// }
195    /// assert!(sniff_xml_encoding(&disagreeing).has_conflicting_declaration());
196    /// ```
197    #[must_use]
198    pub fn has_conflicting_declaration(&self) -> bool {
199        let (Some(declared), Some(from_bytes)) = (
200            self.declared_encoding,
201            self.byte_order_guess
202                .and_then(ByteOrderGuess::supported_encoding),
203        ) else {
204            return false;
205        };
206        match from_bytes {
207            // The UTF-16 guesses are exact (a BOM or the Appendix F.1 pattern pins the byte
208            // order), so anything else declared -- another byte order, or another family
209            // entirely -- contradicts them. The bare label `UTF-16` is exempt: it names the
210            // family without committing to a byte order, so it agrees with either.
211            Encoding::Utf16Le | Encoding::Utf16Be => {
212                if self
213                    .declared_label
214                    .as_deref()
215                    .is_some_and(|label| label.eq_ignore_ascii_case("utf-16"))
216                {
217                    false
218                } else {
219                    declared != from_bytes
220                }
221            }
222            // `ByteOrderGuess::Utf8` only means "ASCII-compatible" (see
223            // `ByteOrderGuess::supported_encoding`), so a declaration naming any other
224            // ASCII-compatible encoding refines rather than contradicts it. Only a UTF-16
225            // declaration, which is not ASCII-compatible at all, is a genuine conflict.
226            _ => matches!(declared, Encoding::Utf16Le | Encoding::Utf16Be),
227        }
228    }
229}
230
231/// Sniff the encoding of an XML entity from its leading bytes, per
232/// [XML 1.0 Appendix F](https://www.w3.org/TR/xml/#sec-guessing).
233///
234/// See the module-level documentation for the two-stage algorithm this implements, and
235/// [`SniffResult`] for how to interpret the result.
236///
237/// # Examples
238///
239/// ```
240/// use oxixml_encoding::{sniff_xml_encoding, ByteOrderGuess, Encoding};
241///
242/// // Appendix F pattern: "<?xm" as UTF-16BE with no BOM.
243/// let utf16be_prologue: &[u8] = &[0x00, b'<', 0x00, b'?', 0x00, b'x', 0x00, b'm'];
244/// let result = sniff_xml_encoding(utf16be_prologue);
245/// assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Be));
246/// assert_eq!(result.bom_len, 0);
247///
248/// // A UTF-8 BOM plus an explicit (if redundant) encoding declaration.
249/// let mut with_bom = vec![0xEF, 0xBB, 0xBF];
250/// with_bom.extend_from_slice(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?><doc/>");
251/// let result = sniff_xml_encoding(&with_bom);
252/// assert_eq!(result.bom_len, 3);
253/// assert_eq!(result.declared_encoding, Some(Encoding::Utf8));
254/// ```
255#[must_use]
256pub fn sniff_xml_encoding(bytes: &[u8]) -> SniffResult {
257    let (guess, bom_len) = guess_and_bom_len(bytes);
258    let window = &bytes[bom_len..];
259    let (declared_label, declared_encoding) = match guess {
260        // UTF-32 and EBCDIC prologues are out of scope for this crate's ASCII/UTF-16 scanner.
261        Some(
262            ByteOrderGuess::Utf32Le
263            | ByteOrderGuess::Utf32Be
264            | ByteOrderGuess::Utf32Order2143
265            | ByteOrderGuess::Utf32Order3412
266            | ByteOrderGuess::Ebcdic,
267        ) => (None, None),
268        // No recognizable pattern at all: per Appendix F, fall back to assuming an
269        // ASCII-compatible encoding and scan for a declaration anyway.
270        Some(ByteOrderGuess::Utf8) | None => scan_declaration(window, ByteOrderGuess::Utf8),
271        Some(g @ (ByteOrderGuess::Utf16Le | ByteOrderGuess::Utf16Be)) => {
272            scan_declaration(window, g)
273        }
274    };
275    SniffResult {
276        byte_order_guess: guess,
277        bom_len,
278        declared_label,
279        declared_encoding,
280    }
281}
282
283/// The full XML 1.0 Appendix F.1 heuristic table, in the precedence order the spec lists it:
284/// the four-byte UCS-4/UTF-16-without-BOM/UTF-8/EBCDIC patterns are checked first (since some of
285/// them share their first two bytes with a plain UTF-16 byte-order mark, e.g. `FF FE 00 00`
286/// starts with the same two bytes as the `FF FE` UTF-16LE BOM), and only once none of those match
287/// does a bare two- or three-byte BOM get considered.
288///
289/// Returns the guessed family together with the number of leading bytes that are a byte-order
290/// mark and should be skipped before decoding (`0` for the no-BOM four-byte patterns, since
291/// those four bytes are themselves the start of `<?xm` and must be decoded, not skipped).
292fn guess_and_bom_len(bytes: &[u8]) -> (Option<ByteOrderGuess>, usize) {
293    if bytes.len() >= 4 {
294        let guess = match [bytes[0], bytes[1], bytes[2], bytes[3]] {
295            [0x00, 0x00, 0xFE, 0xFF] => Some(ByteOrderGuess::Utf32Be),
296            [0xFF, 0xFE, 0x00, 0x00] => Some(ByteOrderGuess::Utf32Le),
297            [0x00, 0x00, 0xFF, 0xFE] => Some(ByteOrderGuess::Utf32Order2143),
298            [0xFE, 0xFF, 0x00, 0x00] => Some(ByteOrderGuess::Utf32Order3412),
299            [0x00, 0x3C, 0x00, 0x3F] => Some(ByteOrderGuess::Utf16Be),
300            [0x3C, 0x00, 0x3F, 0x00] => Some(ByteOrderGuess::Utf16Le),
301            [0x3C, 0x3F, 0x78, 0x6D] => Some(ByteOrderGuess::Utf8),
302            [0x4C, 0x6F, 0xA7, 0x94] => Some(ByteOrderGuess::Ebcdic),
303            _ => None,
304        };
305        if let Some(guess) = guess {
306            return (Some(guess), 0);
307        }
308    }
309    if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
310        (Some(ByteOrderGuess::Utf8), 3)
311    } else if bytes.starts_with(&[0xFE, 0xFF]) {
312        (Some(ByteOrderGuess::Utf16Be), 2)
313    } else if bytes.starts_with(&[0xFF, 0xFE]) {
314        (Some(ByteOrderGuess::Utf16Le), 2)
315    } else {
316        (None, 0)
317    }
318}
319
320/// Scan `window` (the entity's bytes with any BOM already stripped) for an `encoding="..."`
321/// pseudo-attribute, decoding just enough of it to do so given `guess`'s byte width.
322fn scan_declaration(window: &[u8], guess: ByteOrderGuess) -> (Option<String>, Option<Encoding>) {
323    let logical_ascii_bytes: Vec<u8> = match guess {
324        ByteOrderGuess::Utf16Le => decode_prologue_utf16(window, false),
325        ByteOrderGuess::Utf16Be => decode_prologue_utf16(window, true),
326        _ => {
327            let limit = window.len().min(SCAN_LIMIT_BYTES);
328            window[..limit].to_vec()
329        }
330    };
331    parse_encoding_pseudo_attribute(&logical_ascii_bytes)
332}
333
334/// Decode a bounded prefix of `window` as UTF-16, lossily (a sniffer must never itself fail on
335/// malformed input), producing a plain byte buffer suitable for the same ASCII-oriented scan
336/// used for single-byte encodings. This works because the XML declaration itself is required to
337/// be pure ASCII, so its UTF-8 re-encoding (which is what `String` gives us) is byte-identical to
338/// the ASCII text originally carried one-per-UTF-16-code-unit.
339fn decode_prologue_utf16(window: &[u8], big_endian: bool) -> Vec<u8> {
340    let limit = window.len().min(SCAN_LIMIT_BYTES * 2);
341    let encoding = if big_endian {
342        Encoding::Utf16Be
343    } else {
344        Encoding::Utf16Le
345    };
346    let mut decoder = encoding.new_decoder();
347    let mut text = String::new();
348    decoder.decode_to_string_lossy(&window[..limit], &mut text, true);
349    text.into_bytes()
350}
351
352/// Locate `needle` in `haystack`, if present.
353fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
354    if needle.is_empty() || haystack.len() < needle.len() {
355        return None;
356    }
357    haystack
358        .windows(needle.len())
359        .position(|window| window == needle)
360}
361
362/// Parse `<?xml ... encoding="LABEL" ... ?>` (or with single quotes) out of `bytes`, which is
363/// assumed to already be a plain ASCII-compatible byte sequence (see [`decode_prologue_utf16`]
364/// and the direct single-byte path in [`scan_declaration`]).
365fn parse_encoding_pseudo_attribute(bytes: &[u8]) -> (Option<String>, Option<Encoding>) {
366    if !bytes.starts_with(b"<?xml") {
367        return (None, None);
368    }
369    let declaration_end = find_subslice(bytes, b"?>").unwrap_or(bytes.len());
370    let declaration = &bytes[..declaration_end];
371
372    let Some(keyword_at) = find_subslice(declaration, b"encoding") else {
373        return (None, None);
374    };
375    let mut i = keyword_at + b"encoding".len();
376
377    i += skip_ascii_whitespace(&declaration[i..]);
378    if declaration.get(i) != Some(&b'=') {
379        return (None, None);
380    }
381    i += 1;
382    i += skip_ascii_whitespace(&declaration[i..]);
383
384    let Some(&quote) = declaration.get(i) else {
385        return (None, None);
386    };
387    if quote != b'"' && quote != b'\'' {
388        return (None, None);
389    }
390    i += 1;
391    let value_start = i;
392    while declaration.get(i).is_some_and(|&b| b != quote) {
393        i += 1;
394    }
395    if i >= declaration.len() {
396        // Unterminated quote: the declaration was cut off before we could find the closing one.
397        return (None, None);
398    }
399    let raw_label = &declaration[value_start..i];
400
401    // `EncName ::= [A-Za-z] ([A-Za-z0-9._] | '-')*` (XML 1.0 §4.3.3): reject anything that
402    // couldn't possibly be a valid encoding name rather than propagate garbage.
403    if raw_label.is_empty()
404        || !raw_label[0].is_ascii_alphabetic()
405        || !raw_label
406            .iter()
407            .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
408    {
409        return (None, None);
410    }
411
412    let Ok(label) = core::str::from_utf8(raw_label) else {
413        return (None, None);
414    };
415    let label = label.to_string();
416    let encoding = Encoding::for_label(&label);
417    (Some(label), encoding)
418}
419
420fn skip_ascii_whitespace(bytes: &[u8]) -> usize {
421    bytes
422        .iter()
423        .position(|b| !b.is_ascii_whitespace())
424        .unwrap_or(bytes.len())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn no_bom_no_declaration_defaults_to_utf8() {
433        let result = sniff_xml_encoding(b"<root/>");
434        assert_eq!(result.byte_order_guess, None);
435        assert_eq!(result.bom_len, 0);
436        assert_eq!(result.declared_label, None);
437        assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
438    }
439
440    #[test]
441    fn utf8_bom_alone() {
442        let mut bytes = vec![0xEF, 0xBB, 0xBF];
443        bytes.extend_from_slice(b"<root/>");
444        let result = sniff_xml_encoding(&bytes);
445        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf8));
446        assert_eq!(result.bom_len, 3);
447        assert_eq!(result.declared_label, None);
448        assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
449    }
450
451    #[test]
452    fn utf8_bom_with_redundant_declaration() {
453        let mut bytes = vec![0xEF, 0xBB, 0xBF];
454        bytes.extend_from_slice(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>");
455        let result = sniff_xml_encoding(&bytes);
456        assert_eq!(result.bom_len, 3);
457        assert_eq!(result.declared_label.as_deref(), Some("UTF-8"));
458        assert_eq!(result.declared_encoding, Some(Encoding::Utf8));
459    }
460
461    #[test]
462    fn ascii_prologue_with_windows1252_declaration() {
463        let xml = b"<?xml version=\"1.0\" encoding=\"windows-1252\"?><root/>";
464        let result = sniff_xml_encoding(xml);
465        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf8));
466        assert_eq!(result.declared_label.as_deref(), Some("windows-1252"));
467        assert_eq!(result.declared_encoding, Some(Encoding::Windows1252));
468        assert_eq!(result.best_effort_encoding(), Encoding::Windows1252);
469    }
470
471    #[test]
472    fn single_quoted_declaration_is_recognized() {
473        let xml = b"<?xml version='1.0' encoding='ISO-8859-1'?><root/>";
474        let result = sniff_xml_encoding(xml);
475        assert_eq!(result.declared_label.as_deref(), Some("ISO-8859-1"));
476        assert_eq!(result.declared_encoding, Some(Encoding::Latin1));
477    }
478
479    #[test]
480    fn utf16le_bom_with_declaration() {
481        let mut bytes = vec![0xFF, 0xFE];
482        let text = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root/>";
483        for unit in text.encode_utf16() {
484            bytes.extend_from_slice(&unit.to_le_bytes());
485        }
486        let result = sniff_xml_encoding(&bytes);
487        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Le));
488        assert_eq!(result.bom_len, 2);
489        assert_eq!(result.declared_label.as_deref(), Some("UTF-16"));
490        assert_eq!(result.declared_encoding, Some(Encoding::Utf16Le));
491    }
492
493    #[test]
494    fn utf16be_no_bom_pattern_detected_from_first_four_bytes() {
495        let text = "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><root/>";
496        let mut bytes = Vec::new();
497        for unit in text.encode_utf16() {
498            bytes.extend_from_slice(&unit.to_be_bytes());
499        }
500        let result = sniff_xml_encoding(&bytes);
501        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Be));
502        assert_eq!(result.bom_len, 0);
503        assert_eq!(result.declared_label.as_deref(), Some("UTF-16BE"));
504        assert_eq!(result.declared_encoding, Some(Encoding::Utf16Be));
505    }
506
507    #[test]
508    fn utf16le_no_bom_pattern_detected_from_first_four_bytes() {
509        let text = "<?xml version=\"1.0\"?><root/>";
510        let mut bytes = Vec::new();
511        for unit in text.encode_utf16() {
512            bytes.extend_from_slice(&unit.to_le_bytes());
513        }
514        let result = sniff_xml_encoding(&bytes);
515        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Le));
516        assert_eq!(result.declared_label, None);
517        assert_eq!(result.best_effort_encoding(), Encoding::Utf16Le);
518    }
519
520    #[test]
521    fn ascii_compatible_four_byte_pattern_3c_3f_78_6d() {
522        let result = sniff_xml_encoding(b"<?xml version=\"1.0\"?><root/>");
523        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf8));
524    }
525
526    #[test]
527    fn utf32_be_pattern_is_recognized_but_unsupported() {
528        let bytes = [0x00, 0x00, 0xFE, 0xFF, b'<', b'?', b'x', b'm', b'l'];
529        let result = sniff_xml_encoding(&bytes);
530        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf32Be));
531        assert_eq!(
532            result
533                .byte_order_guess
534                .and_then(ByteOrderGuess::supported_encoding),
535            None
536        );
537        assert_eq!(result.declared_label, None);
538        // The overall best-effort fallback is still well-defined (UTF-8), even though it will
539        // almost certainly be wrong for genuine UTF-32 content; callers that care should check
540        // `byte_order_guess` explicitly and reject/report unsupported families themselves.
541        assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
542    }
543
544    #[test]
545    fn utf32_le_pattern_is_recognized() {
546        let bytes = [0xFF, 0xFE, 0x00, 0x00];
547        let result = sniff_xml_encoding(&bytes);
548        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf32Le));
549    }
550
551    #[test]
552    fn utf32_unusual_order_patterns_are_recognized() {
553        assert_eq!(
554            sniff_xml_encoding(&[0x00, 0x00, 0xFF, 0xFE]).byte_order_guess,
555            Some(ByteOrderGuess::Utf32Order2143)
556        );
557        assert_eq!(
558            sniff_xml_encoding(&[0xFE, 0xFF, 0x00, 0x00]).byte_order_guess,
559            Some(ByteOrderGuess::Utf32Order3412)
560        );
561    }
562
563    #[test]
564    fn ebcdic_pattern_is_recognized_but_unsupported() {
565        let bytes = [0x4C, 0x6F, 0xA7, 0x94];
566        let result = sniff_xml_encoding(&bytes);
567        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Ebcdic));
568        assert_eq!(result.declared_label, None);
569    }
570
571    #[test]
572    fn declaration_without_encoding_pseudo_attribute() {
573        let result = sniff_xml_encoding(b"<?xml version=\"1.0\"?><root/>");
574        assert_eq!(result.declared_label, None);
575        assert_eq!(result.declared_encoding, None);
576    }
577
578    #[test]
579    fn unrecognized_declared_label_is_still_captured() {
580        // "windows-874" (Thai) is outside this crate's current scope.
581        let xml = b"<?xml version=\"1.0\" encoding=\"windows-874\"?><root/>";
582        let result = sniff_xml_encoding(xml);
583        assert_eq!(result.declared_label.as_deref(), Some("windows-874"));
584        assert_eq!(result.declared_encoding, None);
585        // Falls back to the byte-order guess (ASCII-compatible => UTF-8) since the declared
586        // label, though present, was not one this crate can decode.
587        assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
588    }
589
590    #[test]
591    fn empty_input_does_not_panic() {
592        let result = sniff_xml_encoding(b"");
593        assert_eq!(result.byte_order_guess, None);
594        assert_eq!(result.declared_label, None);
595        assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
596    }
597
598    #[test]
599    fn truncated_declaration_without_closing_quote_does_not_panic() {
600        let result = sniff_xml_encoding(b"<?xml encoding=\"UTF-8");
601        assert_eq!(result.declared_label, None);
602    }
603
604    #[test]
605    fn content_not_starting_with_xml_declaration_has_no_declared_label() {
606        let result = sniff_xml_encoding(b"   <!-- comment --><root/>");
607        assert_eq!(result.declared_label, None);
608    }
609
610    /// Appendix F sniffing needs no encoding-specific logic beyond [`Encoding::for_label`]: an
611    /// `<?xml ... encoding="LABEL" ?>` declaration is required (XML 1.0 §2.8/§4.3.3) to be pure
612    /// ASCII, so it is byte-identical whether the rest of the entity is windows-1250, Shift_JIS,
613    /// EUC-JP, ISO-2022-JP, GBK, gb18030, Big5, or EUC-KR -- all of these pass the same `<?xm` ->
614    /// ASCII-compatible Appendix F.1 byte-pattern check as UTF-8 does. This test is the evidence
615    /// for that claim, one encoding from each newly-added family.
616    #[test]
617    fn appendix_f_recognizes_every_newly_added_encoding_family_by_label() {
618        for (label, expected) in [
619            ("windows-1250", Encoding::Windows1250),
620            ("KOI8-R", Encoding::Koi8R),
621            ("ISO-8859-9", Encoding::Iso8859_9),
622            ("Shift_JIS", Encoding::ShiftJis),
623            ("EUC-JP", Encoding::EucJp),
624            ("ISO-2022-JP", Encoding::Iso2022Jp),
625            ("GBK", Encoding::Gbk),
626            ("gb18030", Encoding::Gb18030),
627            ("Big5", Encoding::Big5),
628            ("EUC-KR", Encoding::EucKr),
629        ] {
630            let xml = format!("<?xml version=\"1.0\" encoding=\"{label}\"?><root/>");
631            let result = sniff_xml_encoding(xml.as_bytes());
632            assert_eq!(
633                result.byte_order_guess,
634                Some(ByteOrderGuess::Utf8),
635                "label={label}"
636            );
637            assert_eq!(
638                result.declared_label.as_deref(),
639                Some(label),
640                "label={label}"
641            );
642            assert_eq!(result.declared_encoding, Some(expected), "label={label}");
643            assert_eq!(result.best_effort_encoding(), expected, "label={label}");
644        }
645    }
646
647    /// Encodes `text` as UTF-16 with the given byte order, optionally prefixed by a BOM.
648    fn utf16_document(text: &str, big_endian: bool, bom: bool) -> Vec<u8> {
649        let mut bytes = Vec::new();
650        if bom {
651            bytes.extend_from_slice(if big_endian {
652                &[0xFE, 0xFF]
653            } else {
654                &[0xFF, 0xFE]
655            });
656        }
657        for unit in text.encode_utf16() {
658            if big_endian {
659                bytes.extend_from_slice(&unit.to_be_bytes());
660            } else {
661                bytes.extend_from_slice(&unit.to_le_bytes());
662            }
663        }
664        bytes
665    }
666
667    /// Regression: the label `UTF-16` carries no endianness (XML 1.0 Appendix F / RFC 2781), so
668    /// it must never override the byte order established by the BOM or by the Appendix F.1
669    /// four-byte pattern. `Encoding::for_label("UTF-16")` resolves to `Utf16Le` (WHATWG's
670    /// arbitrary default), which used to win outright and decode every big-endian document as
671    /// byte-swapped mojibake.
672    #[test]
673    fn bare_utf16_label_does_not_override_big_endian_byte_order() {
674        let text = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root>hi</root>";
675        for bom in [true, false] {
676            let bytes = utf16_document(text, true, bom);
677            let result = sniff_xml_encoding(&bytes);
678            assert_eq!(
679                result.byte_order_guess,
680                Some(ByteOrderGuess::Utf16Be),
681                "bom={bom}"
682            );
683            assert_eq!(
684                result.declared_label.as_deref(),
685                Some("UTF-16"),
686                "bom={bom}"
687            );
688            assert_eq!(
689                result.best_effort_encoding(),
690                Encoding::Utf16Be,
691                "bom={bom}"
692            );
693            assert!(!result.has_conflicting_declaration(), "bom={bom}");
694
695            // And the whole document really does decode back to its original text.
696            let mut decoder = result.best_effort_encoding().new_decoder();
697            let mut decoded = String::new();
698            decoder.decode_to_string_lossy(&bytes[result.bom_len..], &mut decoded, true);
699            assert_eq!(decoded, text, "bom={bom}");
700        }
701    }
702
703    /// The little-endian mirror of the above: the same bare label must keep little-endian
704    /// little-endian.
705    #[test]
706    fn bare_utf16_label_keeps_little_endian_byte_order() {
707        let text = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root>hi</root>";
708        for bom in [true, false] {
709            let bytes = utf16_document(text, false, bom);
710            let result = sniff_xml_encoding(&bytes);
711            assert_eq!(
712                result.byte_order_guess,
713                Some(ByteOrderGuess::Utf16Le),
714                "bom={bom}"
715            );
716            assert_eq!(
717                result.best_effort_encoding(),
718                Encoding::Utf16Le,
719                "bom={bom}"
720            );
721            assert!(!result.has_conflicting_declaration(), "bom={bom}");
722        }
723    }
724
725    /// An *explicit* endianness in the declaration that contradicts the bytes is a fatal error
726    /// per XML 1.0 §4.3.3; best-effort decoding still follows the bytes (the declaration could
727    /// only be read at all because the bytes were decoded that way), and the contradiction is
728    /// reported through `has_conflicting_declaration`.
729    #[test]
730    fn explicitly_wrong_endianness_declaration_follows_the_bytes_and_is_flagged() {
731        let bytes = utf16_document(
732            "<?xml version=\"1.0\" encoding=\"UTF-16LE\"?><root/>",
733            true,
734            true,
735        );
736        let result = sniff_xml_encoding(&bytes);
737        assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Be));
738        assert_eq!(result.declared_encoding, Some(Encoding::Utf16Le));
739        assert_eq!(result.best_effort_encoding(), Encoding::Utf16Be);
740        assert!(result.has_conflicting_declaration());
741    }
742
743    /// A UTF-16 byte order plus a declaration naming an entirely different family is likewise a
744    /// conflict; the (non-ASCII-compatible) bytes still win, since a single-byte decoder cannot
745    /// possibly be right about a document whose prologue was only legible as UTF-16.
746    #[test]
747    fn utf16_bytes_with_single_byte_declaration_follow_the_bytes() {
748        let bytes = utf16_document(
749            "<?xml version=\"1.0\" encoding=\"windows-1252\"?><root/>",
750            true,
751            true,
752        );
753        let result = sniff_xml_encoding(&bytes);
754        assert_eq!(result.declared_encoding, Some(Encoding::Windows1252));
755        assert_eq!(result.best_effort_encoding(), Encoding::Utf16Be);
756        assert!(result.has_conflicting_declaration());
757    }
758
759    /// A declaration that merely refines the coarse "ASCII-compatible" guess is not a conflict,
760    /// but claiming UTF-16 from ASCII-compatible bytes is.
761    #[test]
762    fn ascii_compatible_declarations_conflict_only_with_utf16() {
763        let refined = sniff_xml_encoding(b"<?xml version=\"1.0\" encoding=\"Shift_JIS\"?><r/>");
764        assert!(!refined.has_conflicting_declaration());
765        assert_eq!(refined.best_effort_encoding(), Encoding::ShiftJis);
766
767        let contradictory = sniff_xml_encoding(b"<?xml version=\"1.0\" encoding=\"UTF-16\"?><r/>");
768        assert!(contradictory.has_conflicting_declaration());
769
770        let no_declaration = sniff_xml_encoding(b"<root/>");
771        assert!(!no_declaration.has_conflicting_declaration());
772    }
773
774    #[test]
775    fn every_supported_encodings_name_is_recognized_when_declared() {
776        for &encoding in Encoding::ALL {
777            let xml = format!(
778                "<?xml version=\"1.0\" encoding=\"{}\"?><root/>",
779                encoding.name()
780            );
781            let result = sniff_xml_encoding(xml.as_bytes());
782            assert_eq!(result.declared_encoding, Some(encoding), "{encoding}");
783        }
784    }
785}