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("e) = 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}