use crate::label::Encoding;
const SCAN_LIMIT_BYTES: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ByteOrderGuess {
Utf8,
Utf16Le,
Utf16Be,
Utf32Le,
Utf32Be,
Utf32Order2143,
Utf32Order3412,
Ebcdic,
}
impl ByteOrderGuess {
#[must_use]
pub const fn supported_encoding(self) -> Option<Encoding> {
match self {
ByteOrderGuess::Utf8 => Some(Encoding::Utf8),
ByteOrderGuess::Utf16Le => Some(Encoding::Utf16Le),
ByteOrderGuess::Utf16Be => Some(Encoding::Utf16Be),
ByteOrderGuess::Utf32Le
| ByteOrderGuess::Utf32Be
| ByteOrderGuess::Utf32Order2143
| ByteOrderGuess::Utf32Order3412
| ByteOrderGuess::Ebcdic => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SniffResult {
pub byte_order_guess: Option<ByteOrderGuess>,
pub bom_len: usize,
pub declared_label: Option<String>,
pub declared_encoding: Option<Encoding>,
}
impl SniffResult {
#[must_use]
pub fn best_effort_encoding(&self) -> Encoding {
let byte_order_encoding = self
.byte_order_guess
.and_then(ByteOrderGuess::supported_encoding);
if let Some(from_bytes @ (Encoding::Utf16Le | Encoding::Utf16Be)) = byte_order_encoding {
return from_bytes;
}
if let Some(encoding) = self.declared_encoding {
return encoding;
}
if let Some(encoding) = byte_order_encoding {
return encoding;
}
Encoding::Utf8
}
#[must_use]
pub fn has_conflicting_declaration(&self) -> bool {
let (Some(declared), Some(from_bytes)) = (
self.declared_encoding,
self.byte_order_guess
.and_then(ByteOrderGuess::supported_encoding),
) else {
return false;
};
match from_bytes {
Encoding::Utf16Le | Encoding::Utf16Be => {
if self
.declared_label
.as_deref()
.is_some_and(|label| label.eq_ignore_ascii_case("utf-16"))
{
false
} else {
declared != from_bytes
}
}
_ => matches!(declared, Encoding::Utf16Le | Encoding::Utf16Be),
}
}
}
#[must_use]
pub fn sniff_xml_encoding(bytes: &[u8]) -> SniffResult {
let (guess, bom_len) = guess_and_bom_len(bytes);
let window = &bytes[bom_len..];
let (declared_label, declared_encoding) = match guess {
Some(
ByteOrderGuess::Utf32Le
| ByteOrderGuess::Utf32Be
| ByteOrderGuess::Utf32Order2143
| ByteOrderGuess::Utf32Order3412
| ByteOrderGuess::Ebcdic,
) => (None, None),
Some(ByteOrderGuess::Utf8) | None => scan_declaration(window, ByteOrderGuess::Utf8),
Some(g @ (ByteOrderGuess::Utf16Le | ByteOrderGuess::Utf16Be)) => {
scan_declaration(window, g)
}
};
SniffResult {
byte_order_guess: guess,
bom_len,
declared_label,
declared_encoding,
}
}
fn guess_and_bom_len(bytes: &[u8]) -> (Option<ByteOrderGuess>, usize) {
if bytes.len() >= 4 {
let guess = match [bytes[0], bytes[1], bytes[2], bytes[3]] {
[0x00, 0x00, 0xFE, 0xFF] => Some(ByteOrderGuess::Utf32Be),
[0xFF, 0xFE, 0x00, 0x00] => Some(ByteOrderGuess::Utf32Le),
[0x00, 0x00, 0xFF, 0xFE] => Some(ByteOrderGuess::Utf32Order2143),
[0xFE, 0xFF, 0x00, 0x00] => Some(ByteOrderGuess::Utf32Order3412),
[0x00, 0x3C, 0x00, 0x3F] => Some(ByteOrderGuess::Utf16Be),
[0x3C, 0x00, 0x3F, 0x00] => Some(ByteOrderGuess::Utf16Le),
[0x3C, 0x3F, 0x78, 0x6D] => Some(ByteOrderGuess::Utf8),
[0x4C, 0x6F, 0xA7, 0x94] => Some(ByteOrderGuess::Ebcdic),
_ => None,
};
if let Some(guess) = guess {
return (Some(guess), 0);
}
}
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
(Some(ByteOrderGuess::Utf8), 3)
} else if bytes.starts_with(&[0xFE, 0xFF]) {
(Some(ByteOrderGuess::Utf16Be), 2)
} else if bytes.starts_with(&[0xFF, 0xFE]) {
(Some(ByteOrderGuess::Utf16Le), 2)
} else {
(None, 0)
}
}
fn scan_declaration(window: &[u8], guess: ByteOrderGuess) -> (Option<String>, Option<Encoding>) {
let logical_ascii_bytes: Vec<u8> = match guess {
ByteOrderGuess::Utf16Le => decode_prologue_utf16(window, false),
ByteOrderGuess::Utf16Be => decode_prologue_utf16(window, true),
_ => {
let limit = window.len().min(SCAN_LIMIT_BYTES);
window[..limit].to_vec()
}
};
parse_encoding_pseudo_attribute(&logical_ascii_bytes)
}
fn decode_prologue_utf16(window: &[u8], big_endian: bool) -> Vec<u8> {
let limit = window.len().min(SCAN_LIMIT_BYTES * 2);
let encoding = if big_endian {
Encoding::Utf16Be
} else {
Encoding::Utf16Le
};
let mut decoder = encoding.new_decoder();
let mut text = String::new();
decoder.decode_to_string_lossy(&window[..limit], &mut text, true);
text.into_bytes()
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn parse_encoding_pseudo_attribute(bytes: &[u8]) -> (Option<String>, Option<Encoding>) {
if !bytes.starts_with(b"<?xml") {
return (None, None);
}
let declaration_end = find_subslice(bytes, b"?>").unwrap_or(bytes.len());
let declaration = &bytes[..declaration_end];
let Some(keyword_at) = find_subslice(declaration, b"encoding") else {
return (None, None);
};
let mut i = keyword_at + b"encoding".len();
i += skip_ascii_whitespace(&declaration[i..]);
if declaration.get(i) != Some(&b'=') {
return (None, None);
}
i += 1;
i += skip_ascii_whitespace(&declaration[i..]);
let Some("e) = declaration.get(i) else {
return (None, None);
};
if quote != b'"' && quote != b'\'' {
return (None, None);
}
i += 1;
let value_start = i;
while declaration.get(i).is_some_and(|&b| b != quote) {
i += 1;
}
if i >= declaration.len() {
return (None, None);
}
let raw_label = &declaration[value_start..i];
if raw_label.is_empty()
|| !raw_label[0].is_ascii_alphabetic()
|| !raw_label
.iter()
.all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return (None, None);
}
let Ok(label) = core::str::from_utf8(raw_label) else {
return (None, None);
};
let label = label.to_string();
let encoding = Encoding::for_label(&label);
(Some(label), encoding)
}
fn skip_ascii_whitespace(bytes: &[u8]) -> usize {
bytes
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(bytes.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_bom_no_declaration_defaults_to_utf8() {
let result = sniff_xml_encoding(b"<root/>");
assert_eq!(result.byte_order_guess, None);
assert_eq!(result.bom_len, 0);
assert_eq!(result.declared_label, None);
assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
}
#[test]
fn utf8_bom_alone() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"<root/>");
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf8));
assert_eq!(result.bom_len, 3);
assert_eq!(result.declared_label, None);
assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
}
#[test]
fn utf8_bom_with_redundant_declaration() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>");
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.bom_len, 3);
assert_eq!(result.declared_label.as_deref(), Some("UTF-8"));
assert_eq!(result.declared_encoding, Some(Encoding::Utf8));
}
#[test]
fn ascii_prologue_with_windows1252_declaration() {
let xml = b"<?xml version=\"1.0\" encoding=\"windows-1252\"?><root/>";
let result = sniff_xml_encoding(xml);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf8));
assert_eq!(result.declared_label.as_deref(), Some("windows-1252"));
assert_eq!(result.declared_encoding, Some(Encoding::Windows1252));
assert_eq!(result.best_effort_encoding(), Encoding::Windows1252);
}
#[test]
fn single_quoted_declaration_is_recognized() {
let xml = b"<?xml version='1.0' encoding='ISO-8859-1'?><root/>";
let result = sniff_xml_encoding(xml);
assert_eq!(result.declared_label.as_deref(), Some("ISO-8859-1"));
assert_eq!(result.declared_encoding, Some(Encoding::Latin1));
}
#[test]
fn utf16le_bom_with_declaration() {
let mut bytes = vec![0xFF, 0xFE];
let text = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root/>";
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Le));
assert_eq!(result.bom_len, 2);
assert_eq!(result.declared_label.as_deref(), Some("UTF-16"));
assert_eq!(result.declared_encoding, Some(Encoding::Utf16Le));
}
#[test]
fn utf16be_no_bom_pattern_detected_from_first_four_bytes() {
let text = "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><root/>";
let mut bytes = Vec::new();
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_be_bytes());
}
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Be));
assert_eq!(result.bom_len, 0);
assert_eq!(result.declared_label.as_deref(), Some("UTF-16BE"));
assert_eq!(result.declared_encoding, Some(Encoding::Utf16Be));
}
#[test]
fn utf16le_no_bom_pattern_detected_from_first_four_bytes() {
let text = "<?xml version=\"1.0\"?><root/>";
let mut bytes = Vec::new();
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Le));
assert_eq!(result.declared_label, None);
assert_eq!(result.best_effort_encoding(), Encoding::Utf16Le);
}
#[test]
fn ascii_compatible_four_byte_pattern_3c_3f_78_6d() {
let result = sniff_xml_encoding(b"<?xml version=\"1.0\"?><root/>");
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf8));
}
#[test]
fn utf32_be_pattern_is_recognized_but_unsupported() {
let bytes = [0x00, 0x00, 0xFE, 0xFF, b'<', b'?', b'x', b'm', b'l'];
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf32Be));
assert_eq!(
result
.byte_order_guess
.and_then(ByteOrderGuess::supported_encoding),
None
);
assert_eq!(result.declared_label, None);
assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
}
#[test]
fn utf32_le_pattern_is_recognized() {
let bytes = [0xFF, 0xFE, 0x00, 0x00];
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf32Le));
}
#[test]
fn utf32_unusual_order_patterns_are_recognized() {
assert_eq!(
sniff_xml_encoding(&[0x00, 0x00, 0xFF, 0xFE]).byte_order_guess,
Some(ByteOrderGuess::Utf32Order2143)
);
assert_eq!(
sniff_xml_encoding(&[0xFE, 0xFF, 0x00, 0x00]).byte_order_guess,
Some(ByteOrderGuess::Utf32Order3412)
);
}
#[test]
fn ebcdic_pattern_is_recognized_but_unsupported() {
let bytes = [0x4C, 0x6F, 0xA7, 0x94];
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Ebcdic));
assert_eq!(result.declared_label, None);
}
#[test]
fn declaration_without_encoding_pseudo_attribute() {
let result = sniff_xml_encoding(b"<?xml version=\"1.0\"?><root/>");
assert_eq!(result.declared_label, None);
assert_eq!(result.declared_encoding, None);
}
#[test]
fn unrecognized_declared_label_is_still_captured() {
let xml = b"<?xml version=\"1.0\" encoding=\"windows-874\"?><root/>";
let result = sniff_xml_encoding(xml);
assert_eq!(result.declared_label.as_deref(), Some("windows-874"));
assert_eq!(result.declared_encoding, None);
assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
}
#[test]
fn empty_input_does_not_panic() {
let result = sniff_xml_encoding(b"");
assert_eq!(result.byte_order_guess, None);
assert_eq!(result.declared_label, None);
assert_eq!(result.best_effort_encoding(), Encoding::Utf8);
}
#[test]
fn truncated_declaration_without_closing_quote_does_not_panic() {
let result = sniff_xml_encoding(b"<?xml encoding=\"UTF-8");
assert_eq!(result.declared_label, None);
}
#[test]
fn content_not_starting_with_xml_declaration_has_no_declared_label() {
let result = sniff_xml_encoding(b" <!-- comment --><root/>");
assert_eq!(result.declared_label, None);
}
#[test]
fn appendix_f_recognizes_every_newly_added_encoding_family_by_label() {
for (label, expected) in [
("windows-1250", Encoding::Windows1250),
("KOI8-R", Encoding::Koi8R),
("ISO-8859-9", Encoding::Iso8859_9),
("Shift_JIS", Encoding::ShiftJis),
("EUC-JP", Encoding::EucJp),
("ISO-2022-JP", Encoding::Iso2022Jp),
("GBK", Encoding::Gbk),
("gb18030", Encoding::Gb18030),
("Big5", Encoding::Big5),
("EUC-KR", Encoding::EucKr),
] {
let xml = format!("<?xml version=\"1.0\" encoding=\"{label}\"?><root/>");
let result = sniff_xml_encoding(xml.as_bytes());
assert_eq!(
result.byte_order_guess,
Some(ByteOrderGuess::Utf8),
"label={label}"
);
assert_eq!(
result.declared_label.as_deref(),
Some(label),
"label={label}"
);
assert_eq!(result.declared_encoding, Some(expected), "label={label}");
assert_eq!(result.best_effort_encoding(), expected, "label={label}");
}
}
fn utf16_document(text: &str, big_endian: bool, bom: bool) -> Vec<u8> {
let mut bytes = Vec::new();
if bom {
bytes.extend_from_slice(if big_endian {
&[0xFE, 0xFF]
} else {
&[0xFF, 0xFE]
});
}
for unit in text.encode_utf16() {
if big_endian {
bytes.extend_from_slice(&unit.to_be_bytes());
} else {
bytes.extend_from_slice(&unit.to_le_bytes());
}
}
bytes
}
#[test]
fn bare_utf16_label_does_not_override_big_endian_byte_order() {
let text = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root>hi</root>";
for bom in [true, false] {
let bytes = utf16_document(text, true, bom);
let result = sniff_xml_encoding(&bytes);
assert_eq!(
result.byte_order_guess,
Some(ByteOrderGuess::Utf16Be),
"bom={bom}"
);
assert_eq!(
result.declared_label.as_deref(),
Some("UTF-16"),
"bom={bom}"
);
assert_eq!(
result.best_effort_encoding(),
Encoding::Utf16Be,
"bom={bom}"
);
assert!(!result.has_conflicting_declaration(), "bom={bom}");
let mut decoder = result.best_effort_encoding().new_decoder();
let mut decoded = String::new();
decoder.decode_to_string_lossy(&bytes[result.bom_len..], &mut decoded, true);
assert_eq!(decoded, text, "bom={bom}");
}
}
#[test]
fn bare_utf16_label_keeps_little_endian_byte_order() {
let text = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root>hi</root>";
for bom in [true, false] {
let bytes = utf16_document(text, false, bom);
let result = sniff_xml_encoding(&bytes);
assert_eq!(
result.byte_order_guess,
Some(ByteOrderGuess::Utf16Le),
"bom={bom}"
);
assert_eq!(
result.best_effort_encoding(),
Encoding::Utf16Le,
"bom={bom}"
);
assert!(!result.has_conflicting_declaration(), "bom={bom}");
}
}
#[test]
fn explicitly_wrong_endianness_declaration_follows_the_bytes_and_is_flagged() {
let bytes = utf16_document(
"<?xml version=\"1.0\" encoding=\"UTF-16LE\"?><root/>",
true,
true,
);
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.byte_order_guess, Some(ByteOrderGuess::Utf16Be));
assert_eq!(result.declared_encoding, Some(Encoding::Utf16Le));
assert_eq!(result.best_effort_encoding(), Encoding::Utf16Be);
assert!(result.has_conflicting_declaration());
}
#[test]
fn utf16_bytes_with_single_byte_declaration_follow_the_bytes() {
let bytes = utf16_document(
"<?xml version=\"1.0\" encoding=\"windows-1252\"?><root/>",
true,
true,
);
let result = sniff_xml_encoding(&bytes);
assert_eq!(result.declared_encoding, Some(Encoding::Windows1252));
assert_eq!(result.best_effort_encoding(), Encoding::Utf16Be);
assert!(result.has_conflicting_declaration());
}
#[test]
fn ascii_compatible_declarations_conflict_only_with_utf16() {
let refined = sniff_xml_encoding(b"<?xml version=\"1.0\" encoding=\"Shift_JIS\"?><r/>");
assert!(!refined.has_conflicting_declaration());
assert_eq!(refined.best_effort_encoding(), Encoding::ShiftJis);
let contradictory = sniff_xml_encoding(b"<?xml version=\"1.0\" encoding=\"UTF-16\"?><r/>");
assert!(contradictory.has_conflicting_declaration());
let no_declaration = sniff_xml_encoding(b"<root/>");
assert!(!no_declaration.has_conflicting_declaration());
}
#[test]
fn every_supported_encodings_name_is_recognized_when_declared() {
for &encoding in Encoding::ALL {
let xml = format!(
"<?xml version=\"1.0\" encoding=\"{}\"?><root/>",
encoding.name()
);
let result = sniff_xml_encoding(xml.as_bytes());
assert_eq!(result.declared_encoding, Some(encoding), "{encoding}");
}
}
}