xml-sec 0.1.20

Pure Rust XML Security: XMLDSig, XMLEnc, C14N. Drop-in replacement for libxmlsec1.
Documentation
//! XML byte-encoding detection shared by process and transform boundaries.

use crate::xml_input as xml_sec_xml_input;
use std::borrow::Cow;

/// Shared decoder errors, including unsupported encodings and decoded-size limits.
/// This replaces the former XML-only error variants rather than collapsing new
/// failure classes into misleading legacy variants.
pub use crate::xml_input_shared::Error as XmlEncodingError;

/// Decode XML 1.0 octets into the backend-neutral Unicode parser contract.
///
/// UTF-8 input remains borrowed. Other declared XML encodings are decoded
/// strictly and their declaration is normalized to UTF-8 before parsing.
/// Callers must supply a size ceiling; an implicit unbounded compatibility path
/// would permit untrusted input to allocate without the operation's budget.
pub fn decode_xml_octets(
    bytes: &[u8],
    maximum_decoded_bytes: usize,
) -> Result<Cow<'_, str>, XmlEncodingError> {
    xml_sec_xml_input::decode_xml_bounded(bytes, None, maximum_decoded_bytes)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn utf16_bytes(xml: &str, little_endian: bool, bom: bool) -> Vec<u8> {
        let mut bytes = if bom {
            if little_endian {
                vec![0xff, 0xfe]
            } else {
                vec![0xfe, 0xff]
            }
        } else {
            Vec::new()
        };
        bytes.extend(xml.encode_utf16().flat_map(|value| {
            if little_endian {
                value.to_le_bytes()
            } else {
                value.to_be_bytes()
            }
        }));
        bytes
    }

    #[test]
    fn transcoding_normalizes_non_utf8_declarations() {
        for bytes in [
            utf16_bytes(
                "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root/>",
                true,
                true,
            ),
            b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><root>caf\xe9</root>".to_vec(),
        ] {
            let decoded =
                decode_xml_octets(&bytes, 1_024).expect("declared XML encoding must decode");
            assert!(decoded.contains("encoding=\"UTF-8\"") || decoded.contains("encoding='UTF-8'"));
        }
    }

    #[test]
    fn declared_single_byte_xml_is_transcoded_strictly() {
        // XML permits declared encodings beyond its mandatory UTF-8/UTF-16 baseline.
        let latin1 = b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><root>caf\xe9</root>";
        let decoded = decode_xml_octets(latin1, 1_024).expect("declared Latin-1 XML must decode");
        assert!(decoded.contains("<root>café</root>"));
        assert!(decoded.contains("encoding=\"UTF-8\""));
    }

    #[test]
    fn byte_signatures_and_declarations_must_agree() {
        let conflicting = utf16_bytes(
            "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><root/>",
            true,
            true,
        );
        assert!(matches!(
            decode_xml_octets(&conflicting, 1_024),
            Err(XmlEncodingError::ConflictingEncoding(_))
        ));

        let matching_signature = utf16_bytes(
            "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><root/>",
            false,
            true,
        );
        assert!(decode_xml_octets(&matching_signature, 1_024).is_ok());

        let bomless_generic = utf16_bytes(
            "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root/>",
            true,
            false,
        );
        assert!(matches!(
            decode_xml_octets(&bomless_generic, 1_024),
            Err(XmlEncodingError::MissingUtf16ByteOrder)
        ));
    }

    #[test]
    fn utf8_is_borrowed_and_malformed_bytes_are_rejected() {
        let xml = b"<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>";
        assert!(matches!(
            decode_xml_octets(xml, xml.len()),
            Ok(Cow::Borrowed(_))
        ));
        assert!(decode_xml_octets(b"<root>\xff</root>", 1_024).is_err());
    }

    #[test]
    fn declaration_detection_ignores_other_processing_instructions() {
        for xml in [
            r#"<?xml-stylesheet encoding="ISO-8859-1"?><root/>"#,
            r#"<root data-encoding="ISO-8859-1"/>"#,
        ] {
            decode_xml_octets(xml.as_bytes(), 1_024)
                .expect("ordinary content does not select encoding");
        }
    }

    #[test]
    fn transcoding_stops_at_the_decoded_document_limit() {
        let bytes = utf16_bytes("<?xml version=\"1.0\"?><root>expanded</root>", true, true);
        assert!(matches!(
            decode_xml_octets(&bytes, 16),
            Err(XmlEncodingError::DecodedLimit { maximum: 16, .. })
        ));
    }
}