Skip to main content

xml_sec/
encoding.rs

1//! XML byte-encoding detection shared by process and transform boundaries.
2
3use std::borrow::Cow;
4
5/// Error returned when XML octets cannot be decoded as required by XML 1.0.
6#[derive(Debug, thiserror::Error)]
7pub enum XmlEncodingError {
8    /// A BOM-marked UTF-16 document ended in a partial code unit.
9    #[error("UTF-16 XML input has an odd byte length")]
10    OddUtf16Length,
11    /// UTF-16 code units did not form valid Unicode scalar values.
12    #[error("invalid UTF-16 XML input: {0}")]
13    InvalidUtf16(#[from] std::string::FromUtf16Error),
14    /// The detected XML byte encoding contradicted the document declaration.
15    #[error("XML byte encoding conflicts with declared encoding {0}")]
16    ConflictingDeclaration(String),
17    /// BOM-less UTF-16 input omitted the required explicit byte-order declaration.
18    #[error("BOM-less UTF-16 XML input requires an explicit UTF-16LE or UTF-16BE declaration")]
19    MissingUtf16Declaration,
20    /// Input without an XML UTF-16 signature was not valid UTF-8.
21    #[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
31/// Decode XML 1.0 octets encoded as UTF-8 or UTF-16.
32///
33/// UTF-16 is recognized from a BOM or the XML declaration byte signature.
34/// BOM-less input must declare the matching explicit `UTF-16LE` or `UTF-16BE`
35/// encoding. Transcoded declarations are normalized to `UTF-8` because the
36/// returned Rust text is subsequently serialized as UTF-8.
37pub 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 &quote @ (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        // Once code units become a Rust string, subsequent serializers emit
180        // UTF-8 bytes; the declaration must describe that representation.
181        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        // XML 1.0 Appendix F permits byte-signature autodetection, while
252        // section 4.3.3 still requires generic UTF-16 to carry a BOM.
253        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        // Successful UTF-8 decoding is not sufficient when the XML declaration
293        // tells downstream processors to interpret the same octets differently.
294        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        // Only the XML declaration's exact encoding pseudo-attribute controls
315        // byte decoding; processing instructions and longer names are content.
316        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}