1use std::borrow::Cow;
4
5#[derive(Debug, thiserror::Error)]
7pub enum XmlEncodingError {
8 #[error("UTF-16 XML input has an odd byte length")]
10 OddUtf16Length,
11 #[error("invalid UTF-16 XML input: {0}")]
13 InvalidUtf16(#[from] std::string::FromUtf16Error),
14 #[error("XML byte encoding conflicts with declared encoding {0}")]
16 ConflictingDeclaration(String),
17 #[error("BOM-less UTF-16 XML input requires an explicit UTF-16LE or UTF-16BE declaration")]
19 MissingUtf16Declaration,
20 #[error("XML input is neither declared UTF-16 nor valid UTF-8: {0}")]
22 InvalidUtf8(#[from] std::str::Utf8Error),
23}
24
25#[derive(Clone, Copy)]
26enum Utf16ByteOrder {
27 LittleEndian,
28 BigEndian,
29}
30
31pub fn decode_xml_octets(bytes: &[u8]) -> Result<Cow<'_, str>, XmlEncodingError> {
38 let utf16 = if let Some(payload) = bytes.strip_prefix(&[0xff, 0xfe]) {
39 Some((payload, Utf16ByteOrder::LittleEndian, false))
40 } else if let Some(payload) = bytes.strip_prefix(&[0xfe, 0xff]) {
41 Some((payload, Utf16ByteOrder::BigEndian, false))
42 } else if bytes.starts_with(&[0x3c, 0x00, 0x3f, 0x00]) {
43 Some((bytes, Utf16ByteOrder::LittleEndian, true))
44 } else if bytes.starts_with(&[0x00, 0x3c, 0x00, 0x3f]) {
45 Some((bytes, Utf16ByteOrder::BigEndian, true))
46 } else {
47 None
48 };
49 if let Some((payload, byte_order, requires_explicit_byte_order)) = utf16 {
50 if payload.len() % 2 != 0 {
51 return Err(XmlEncodingError::OddUtf16Length);
52 }
53 let (chunks, remainder) = payload.as_chunks::<2>();
54 debug_assert!(remainder.is_empty());
55 let code_units = chunks.iter().map(|bytes| {
56 if matches!(byte_order, Utf16ByteOrder::LittleEndian) {
57 u16::from_le_bytes(*bytes)
58 } else {
59 u16::from_be_bytes(*bytes)
60 }
61 });
62 let decoded = String::from_utf16(&code_units.collect::<Vec<_>>())?;
63 return normalize_transcoded_declaration(decoded, byte_order, requires_explicit_byte_order)
64 .map(Cow::Owned);
65 }
66
67 let payload = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
68 let xml = std::str::from_utf8(payload)?;
69 if let Some(range) = declared_encoding_range(xml)
70 && !encoding_label_matches(&xml[range.clone()], "UTF-8")
71 {
72 return Err(XmlEncodingError::ConflictingDeclaration(xml[range].into()));
73 }
74 Ok(Cow::Borrowed(xml))
75}
76
77fn normalize_transcoded_declaration(
78 mut xml: String,
79 byte_order: Utf16ByteOrder,
80 requires_explicit_byte_order: bool,
81) -> Result<String, XmlEncodingError> {
82 let Some(declared_range) = declared_encoding_range(&xml) else {
83 return if requires_explicit_byte_order {
84 Err(XmlEncodingError::MissingUtf16Declaration)
85 } else {
86 Ok(xml)
87 };
88 };
89 let declared = &xml[declared_range.clone()];
90 let explicit_encoding = match byte_order {
91 Utf16ByteOrder::LittleEndian => "UTF-16LE",
92 Utf16ByteOrder::BigEndian => "UTF-16BE",
93 };
94 let declaration_matches = encoding_label_matches(declared, explicit_encoding)
95 || (!requires_explicit_byte_order && encoding_label_matches(declared, "UTF-16"));
96 if !declaration_matches {
97 return Err(XmlEncodingError::ConflictingDeclaration(declared.into()));
98 }
99 xml.replace_range(declared_range, "UTF-8");
100 Ok(xml)
101}
102
103fn declared_encoding_range(xml: &str) -> Option<std::ops::Range<usize>> {
104 const PREFIX: &str = "<?xml";
105 let rest = xml.strip_prefix(PREFIX)?;
106 if !rest.as_bytes().first().is_some_and(u8::is_ascii_whitespace) {
107 return None;
108 }
109 let declaration = rest.as_bytes().get(..rest.find("?>")?)?;
110 let mut cursor = 0;
111 while cursor < declaration.len() {
112 while declaration.get(cursor).is_some_and(u8::is_ascii_whitespace) {
113 cursor += 1;
114 }
115 let name_start = cursor;
116 while declaration.get(cursor).is_some_and(|byte| {
117 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b':' | b'-' | b'.')
118 }) {
119 cursor += 1;
120 }
121 if cursor == name_start {
122 return None;
123 }
124 let name = &declaration[name_start..cursor];
125 while declaration.get(cursor).is_some_and(u8::is_ascii_whitespace) {
126 cursor += 1;
127 }
128 if declaration.get(cursor) != Some(&b'=') {
129 return None;
130 }
131 cursor += 1;
132 while declaration.get(cursor).is_some_and(u8::is_ascii_whitespace) {
133 cursor += 1;
134 }
135 let "e @ (b'\'' | b'"') = declaration.get(cursor)? else {
136 return None;
137 };
138 let value_start = cursor + 1;
139 let value_end = value_start
140 + declaration[value_start..]
141 .iter()
142 .position(|byte| *byte == quote)?;
143 if name == b"encoding" {
144 return Some((PREFIX.len() + value_start)..(PREFIX.len() + value_end));
145 }
146 cursor = value_end + 1;
147 if declaration
148 .get(cursor)
149 .is_some_and(|byte| !byte.is_ascii_whitespace())
150 {
151 return None;
152 }
153 }
154 None
155}
156
157fn encoding_label_matches(actual: &str, canonical: &str) -> bool {
158 actual.eq_ignore_ascii_case(canonical)
159 || matches!(canonical,
160 "UTF-8" if actual.eq_ignore_ascii_case("UTF8")
161 )
162 || matches!(canonical,
163 "UTF-16" if actual.eq_ignore_ascii_case("UTF16")
164 )
165 || matches!(canonical,
166 "UTF-16LE" if actual.eq_ignore_ascii_case("UTF16LE")
167 )
168 || matches!(canonical,
169 "UTF-16BE" if actual.eq_ignore_ascii_case("UTF16BE")
170 )
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn transcoding_normalizes_utf16_declarations() {
179 for (bom, encode, declaration) in [
182 (
183 [0xff, 0xfe],
184 u16::to_le_bytes as fn(u16) -> [u8; 2],
185 "<?xml version=\"1.0\" encoding=\"UTF-16\"?>",
186 ),
187 (
188 [0xff, 0xfe],
189 u16::to_le_bytes as fn(u16) -> [u8; 2],
190 "<?xml version='1.0' encoding = 'utf-16le'?>",
191 ),
192 (
193 [0xfe, 0xff],
194 u16::to_be_bytes as fn(u16) -> [u8; 2],
195 "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?>",
196 ),
197 ] {
198 let mut bytes = bom.to_vec();
199 bytes.extend(
200 format!("{declaration}<root/>")
201 .encode_utf16()
202 .flat_map(encode),
203 );
204 let decoded = decode_xml_octets(&bytes).expect("valid UTF-16 XML declaration");
205 assert!(
206 decoded.contains("encoding=\"UTF-8\"") || decoded.contains("encoding = 'UTF-8'")
207 );
208 }
209 }
210
211 #[test]
212 fn transcoding_rejects_a_conflicting_declaration() {
213 let mut bytes = vec![0xff, 0xfe];
214 bytes.extend(
215 "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><root/>"
216 .encode_utf16()
217 .flat_map(u16::to_le_bytes),
218 );
219 assert!(matches!(
220 decode_xml_octets(&bytes),
221 Err(XmlEncodingError::ConflictingDeclaration(_))
222 ));
223
224 for (bom, encode, declared) in [
225 (
226 [0xff, 0xfe],
227 u16::to_le_bytes as fn(u16) -> [u8; 2],
228 "UTF-16BE",
229 ),
230 (
231 [0xfe, 0xff],
232 u16::to_be_bytes as fn(u16) -> [u8; 2],
233 "UTF-16LE",
234 ),
235 ] {
236 let mut bytes = bom.to_vec();
237 bytes.extend(
238 format!("<?xml version=\"1.0\" encoding=\"{declared}\"?><root/>")
239 .encode_utf16()
240 .flat_map(encode),
241 );
242 assert!(matches!(
243 decode_xml_octets(&bytes),
244 Err(XmlEncodingError::ConflictingDeclaration(_))
245 ));
246 }
247 }
248
249 #[test]
250 fn bomless_utf16_requires_an_explicit_matching_byte_order() {
251 for (encode, declared) in [
254 (u16::to_le_bytes as fn(u16) -> [u8; 2], "UTF-16LE"),
255 (u16::to_be_bytes as fn(u16) -> [u8; 2], "UTF-16BE"),
256 ] {
257 let bytes = format!("<?xml version=\"1.0\" encoding=\"{declared}\"?><root/>")
258 .encode_utf16()
259 .flat_map(encode)
260 .collect::<Vec<_>>();
261 let decoded = decode_xml_octets(&bytes).expect("explicit-endian UTF-16 must decode");
262 assert!(decoded.contains("encoding=\"UTF-8\""));
263 }
264
265 for (encode, declared) in [
266 (u16::to_le_bytes as fn(u16) -> [u8; 2], "UTF-16"),
267 (u16::to_le_bytes as fn(u16) -> [u8; 2], "UTF-16BE"),
268 (u16::to_be_bytes as fn(u16) -> [u8; 2], "UTF-16LE"),
269 ] {
270 let bytes = format!("<?xml version=\"1.0\" encoding=\"{declared}\"?><root/>")
271 .encode_utf16()
272 .flat_map(encode)
273 .collect::<Vec<_>>();
274 assert!(matches!(
275 decode_xml_octets(&bytes),
276 Err(XmlEncodingError::ConflictingDeclaration(value)) if value == declared
277 ));
278 }
279
280 let missing = "<?xml version=\"1.0\"?><root/>"
281 .encode_utf16()
282 .flat_map(u16::to_le_bytes)
283 .collect::<Vec<_>>();
284 assert!(matches!(
285 decode_xml_octets(&missing),
286 Err(XmlEncodingError::MissingUtf16Declaration)
287 ));
288 }
289
290 #[test]
291 fn utf8_input_requires_a_matching_encoding_declaration() {
292 for valid in [
295 br#"<?xml version="1.0" encoding="UTF-8"?><root/>"#.as_slice(),
296 br#"<?xml version='1.0' encoding = 'utf-8'?><root/>"#.as_slice(),
297 br#"<?xml version='1.0' encoding='UTF8'?><root/>"#.as_slice(),
298 b"<root/>".as_slice(),
299 ] {
300 decode_xml_octets(valid).expect("UTF-8 declaration must match UTF-8 octets");
301 }
302
303 for declared in ["UTF-16", "ISO-8859-1"] {
304 let xml = format!("<?xml version=\"1.0\" encoding=\"{declared}\"?><root>\u{e9}</root>");
305 assert!(matches!(
306 decode_xml_octets(xml.as_bytes()),
307 Err(XmlEncodingError::ConflictingDeclaration(value)) if value == declared
308 ));
309 }
310 }
311
312 #[test]
313 fn declaration_detection_ignores_processing_instructions_and_similar_names() {
314 for xml in [
317 r#"<?xml-stylesheet encoding="ISO-8859-1"?><root/>"#,
318 r#"<?xml version="1.0" data-encoding="ISO-8859-1"?><root/>"#,
319 ] {
320 decode_xml_octets(xml.as_bytes()).expect("non-declaration text must be ignored");
321 }
322 }
323}