Skip to main content

xml_sec/
encoding.rs

1//! XML byte-encoding detection shared by process and transform boundaries.
2
3use crate::xml_input as xml_sec_xml_input;
4use std::borrow::Cow;
5
6/// Shared decoder errors, including unsupported encodings and decoded-size limits.
7/// This replaces the former XML-only error variants rather than collapsing new
8/// failure classes into misleading legacy variants.
9pub use crate::xml_input_shared::Error as XmlEncodingError;
10
11#[cfg(feature = "xmldsig")]
12#[derive(Debug)]
13pub(crate) enum DecodeError {
14    Encoding(XmlEncodingError),
15    Label(XmlEncodingError<structured_asn1::memory::OwnedText>),
16    Execution(structured_asn1::memory::MemoryError),
17}
18
19#[cfg(feature = "xmldsig")]
20impl From<XmlEncodingError> for DecodeError {
21    fn from(error: XmlEncodingError) -> Self {
22        Self::Encoding(error)
23    }
24}
25
26#[cfg(feature = "xmldsig")]
27impl From<structured_asn1::memory::MemoryError> for DecodeError {
28    fn from(error: structured_asn1::memory::MemoryError) -> Self {
29        Self::Execution(error)
30    }
31}
32
33#[cfg(feature = "xmldsig")]
34pub(crate) struct AccountedDecoder<'w, W: structured_asn1::memory::Work>(pub &'w W);
35
36#[cfg(feature = "xmldsig")]
37impl<W: structured_asn1::memory::Work> crate::xml_input_shared::DecodeStorage
38    for AccountedDecoder<'_, W>
39{
40    type Text = structured_asn1::memory::OwnedText<W>;
41    type Buffer = structured_asn1::memory::OwnedVec<u8, W>;
42    type Error = DecodeError;
43
44    fn label_error(&self, kind: crate::xml_input_shared::LabelError, label: &str) -> DecodeError {
45        use crate::xml_input_shared::LabelError;
46        let text = match structured_asn1::memory::OwnedText::try_from_str(label, self.0) {
47            Ok(text) => text.into_owned(),
48            Err(error) => return DecodeError::Execution(error),
49        };
50        DecodeError::Label(match kind {
51            LabelError::Unsupported => XmlEncodingError::UnsupportedEncoding(text),
52            LabelError::Forbidden => XmlEncodingError::ForbiddenEncoding(text),
53            LabelError::Conflicting => XmlEncodingError::ConflictingEncoding(text),
54        })
55    }
56
57    fn charge(&self, bytes: usize) -> Result<(), DecodeError> {
58        self.0.charge_work(
59            u64::try_from(bytes).map_err(|_| structured_asn1::memory::MemoryError::WorkExceeded)?,
60        )?;
61        Ok(())
62    }
63    fn buffer(&self) -> Self::Buffer {
64        self.0.vector()
65    }
66    fn len(&self, buffer: &Self::Buffer) -> usize {
67        buffer.len()
68    }
69    fn append(&self, buffer: &mut Self::Buffer, text: &str) -> Result<(), DecodeError> {
70        buffer.try_extend_from_slice(text.as_bytes())?;
71        Ok(())
72    }
73    fn finish(&self, buffer: Self::Buffer) -> Result<Self::Text, DecodeError> {
74        use structured_asn1::memory::{OwnedText, TextErrorKind};
75        OwnedText::from_utf8(buffer).map_err(|error| match error.kind() {
76            TextErrorKind::Resource(error) => DecodeError::Execution(error),
77            TextErrorKind::Utf8(_) => unreachable!("decoder appends only valid UTF-8 fragments"),
78        })
79    }
80    fn replace(
81        &self,
82        text: Self::Text,
83        range: std::ops::Range<usize>,
84        replacement: &str,
85    ) -> Result<Self::Text, DecodeError> {
86        // The common grammar supplied character boundaries from this exact
87        // retained text. Reuse its allocation, admitting moves before mutation.
88        let tail = text.len() - range.end;
89        let old_length = text.len();
90        let new_length = old_length - range.len() + replacement.len();
91        self.charge(tail)?;
92        self.charge(replacement.len())?;
93        self.charge(old_length.saturating_sub(new_length))?;
94        let mut bytes = text.into_bytes();
95        while bytes.len() < new_length {
96            bytes.try_push(0)?;
97        }
98        bytes.copy_within(range.end..old_length, range.start + replacement.len());
99        bytes[range.start..range.start + replacement.len()].copy_from_slice(replacement.as_bytes());
100        while bytes.len() > new_length {
101            bytes.pop();
102        }
103        self.finish(bytes)
104    }
105}
106
107#[cfg(all(test, feature = "xmldsig"))]
108fn decode_xml_octets_in<'a, W: structured_asn1::memory::Work>(
109    bytes: &'a [u8],
110    maximum: usize,
111    work: &W,
112) -> Result<crate::xml_input_shared::Decoded<'a, structured_asn1::memory::OwnedText<W>>, DecodeError>
113{
114    crate::xml_input_shared::decode_xml_in(bytes, maximum, |_, _| Ok(()), &AccountedDecoder(work))
115}
116
117/// Decode XML 1.0 octets into the backend-neutral Unicode parser contract.
118///
119/// UTF-8 input remains borrowed. Other declared XML encodings are decoded
120/// strictly and their declaration is normalized to UTF-8 before parsing.
121/// Callers must supply a size ceiling; an implicit unbounded compatibility path
122/// would permit untrusted input to allocate without the operation's budget.
123pub fn decode_xml_octets(
124    bytes: &[u8],
125    maximum_decoded_bytes: usize,
126) -> Result<Cow<'_, str>, XmlEncodingError> {
127    xml_sec_xml_input::decode_xml_bounded(bytes, None, maximum_decoded_bytes)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    #[cfg(feature = "xmldsig")]
136    fn scoped_transcoding_retains_actual_capacity_until_text_is_dropped() {
137        // Non-UTF-8 output must belong to the requesting operation, including
138        // declaration rewriting; returning the native Cow loses that ownership.
139        use structured_asn1::memory::{Memory, MemoryAccount};
140        let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(4096, 100_000)));
141        let input = b"<?xml version='1.0' encoding='ISO-8859-1'?><r>caf\xe9</r>";
142        let decoded = decode_xml_octets_in(input, 4096, &memory).expect("Latin-1 output fits");
143        assert_eq!(
144            decoded.as_ref(),
145            "<?xml version='1.0' encoding='UTF-8'?><r>café</r>"
146        );
147        assert!(memory.used() >= decoded.as_ref().len());
148        drop(decoded);
149        assert_eq!(memory.used(), 0);
150    }
151
152    #[test]
153    #[cfg(feature = "xmldsig")]
154    fn scoped_transcoding_refuses_capacity_but_borrows_valid_utf8() {
155        // A zero-capacity caller can consume UTF-8 without copying, but must
156        // refuse a real transcoding buffer, before exposing a partial document.
157        use structured_asn1::memory::{Memory, MemoryAccount, MemoryError};
158        let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(0, 100_000)));
159        let utf8 = b"<r>text</r>";
160        let decoded = decode_xml_octets_in(utf8, 4096, &memory).expect("UTF-8 stays borrowed");
161        assert_eq!(decoded.as_ref().as_ptr(), utf8.as_ptr());
162        let input = b"<?xml version='1.0' encoding='ISO-8859-1'?><r>\xe9</r>";
163        assert!(matches!(
164            decode_xml_octets_in(input, 4096, &memory),
165            Err(DecodeError::Execution(MemoryError::LimitExceeded))
166        ));
167        assert_eq!(memory.used(), 0);
168    }
169
170    #[test]
171    #[cfg(feature = "xmldsig")]
172    fn scoped_encoding_diagnostics_do_not_escape_the_capacity_account() {
173        // An attacker-controlled invalid label is itself retained data. A
174        // syntax failure must not allocate a native String outside the query.
175        use structured_asn1::memory::{Memory, MemoryAccount, MemoryError};
176        let label = "Unregistered-".repeat(1024);
177        let input = format!("<?xml version='1.0' encoding='{label}'?><r/>");
178        let denied = Memory::new(std::sync::Arc::new(MemoryAccount::new(0, 1 << 20)));
179        assert!(matches!(
180            decode_xml_octets_in(input.as_bytes(), 1 << 20, &denied),
181            Err(DecodeError::Execution(MemoryError::LimitExceeded))
182        ));
183        assert_eq!(denied.used(), 0);
184        let admitted = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 20)));
185        let error = match decode_xml_octets_in(input.as_bytes(), 1 << 20, &admitted) {
186            Err(error) => error,
187            Ok(_) => panic!("unsupported label must not decode"),
188        };
189        assert!(
190            matches!(&error, DecodeError::Label(XmlEncodingError::UnsupportedEncoding(text)) if text.as_str() == label)
191        );
192        assert!(admitted.used() >= label.len());
193        drop(error);
194        assert_eq!(admitted.used(), 0);
195    }
196
197    #[test]
198    #[cfg(feature = "xmldsig")]
199    fn scoped_transcoding_preserves_local_query_refusal_before_decode() {
200        // Aggregate room must not bypass a zero-work *current* query, even
201        // when malformed input would otherwise allocate a diagnostic label.
202        use structured_asn1::{
203            Encoding, Limits, Parser,
204            memory::{Memory, MemoryAccount, MemoryError},
205        };
206        let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(4096, 100_000)));
207        let parser = Parser::new(
208            Encoding::Der,
209            Limits {
210                max_work: 0,
211                ..Default::default()
212            },
213            memory.clone(),
214        );
215        let reader = parser.read(&[], 0).expect("empty query carrier");
216        for input in [
217            b"<r/>".as_slice(),
218            b"<?xml version='1.0' encoding='not-supported'?><r/>",
219        ] {
220            assert!(matches!(
221                decode_xml_octets_in(input, 4096, &reader.execution()),
222                Err(DecodeError::Execution(MemoryError::WorkExceeded))
223            ));
224        }
225        assert_eq!(memory.used(), 0);
226        assert_eq!(memory.work_used(), 0);
227    }
228
229    #[test]
230    #[cfg(feature = "xmldsig")]
231    fn scoped_transcoding_admits_exact_work_and_refuses_one_less() {
232        // Derive the boundary from actual execution, not a hard-coded charge:
233        // every refused partial output must relinquish its retained capacity.
234        use structured_asn1::memory::{Memory, MemoryAccount, MemoryError};
235        let input = b"<?xml version='1.0' encoding='ISO-8859-1'?><r>\xe9</r>";
236        let baseline = Memory::new(std::sync::Arc::new(MemoryAccount::new(4096, 100_000)));
237        let output = decode_xml_octets_in(input, 4096, &baseline).expect("baseline fits");
238        let used = baseline.work_used();
239        let exact = Memory::new(std::sync::Arc::new(MemoryAccount::new(4096, used)));
240        assert_eq!(
241            decode_xml_octets_in(input, 4096, &exact)
242                .expect("exact work fits")
243                .as_ref(),
244            output.as_ref()
245        );
246        assert_eq!(exact.used(), 0);
247        let short = Memory::new(std::sync::Arc::new(MemoryAccount::new(4096, used - 1)));
248        assert!(matches!(
249            decode_xml_octets_in(input, 4096, &short),
250            Err(DecodeError::Execution(MemoryError::WorkExceeded))
251        ));
252        assert_eq!(short.used(), 0);
253    }
254
255    #[test]
256    #[cfg(feature = "xmldsig")]
257    fn scoped_declaration_rewrite_reuses_the_normalization_allocation() {
258        // Both shrink and growth within existing room must move the suffix in
259        // place, not allocate/copy a second complete decoded representation.
260        use crate::xml_input_shared::DecodeStorage;
261        use structured_asn1::memory::{Memory, MemoryAccount, OwnedText};
262        let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(4096, 100_000)));
263        let storage = AccountedDecoder(&memory);
264        for label in ["ISO-8859-1", "x"] {
265            let mut bytes = memory.vector();
266            bytes.try_reserve_exact(128).expect("test capacity fits");
267            bytes
268                .try_extend_from_slice(b"before:")
269                .expect("prefix fits");
270            bytes
271                .try_extend_from_slice(label.as_bytes())
272                .expect("label fits");
273            bytes.try_extend_from_slice(b":after").expect("suffix fits");
274            let pointer = bytes.as_ptr();
275            let capacity = bytes.capacity();
276            let text = OwnedText::from_utf8(bytes).expect("ASCII test text");
277            let rewritten = storage
278                .replace(text, 7..7 + label.len(), "UTF-8")
279                .expect("rewrite fits");
280            assert_eq!(rewritten.as_str(), "before:UTF-8:after");
281            assert_eq!(rewritten.as_str().as_ptr(), pointer);
282            assert_eq!(memory.used(), capacity);
283            drop(rewritten);
284            assert_eq!(memory.used(), 0);
285        }
286    }
287
288    #[test]
289    #[cfg(feature = "xmldsig")]
290    fn scoped_transcoding_preserves_state_across_bounded_input_chunks() {
291        // Split a UTF-16 surrogate pair at the input-chunk boundary. Decoder
292        // state must carry it across without replacement or an early EOF.
293        use structured_asn1::memory::{Memory, MemoryAccount};
294        let prefix = "<?xml version='1.0' encoding='UTF-16'?><r>";
295        let xml = format!("{prefix}{}😀</r>", "x".repeat(2047 - prefix.len()));
296        for little_endian in [false, true] {
297            let bytes = utf16_bytes(&xml, little_endian, true);
298            let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 20)));
299            let decoded =
300                decode_xml_octets_in(&bytes, 1 << 20, &memory).expect("split pair is valid");
301            assert_eq!(decoded.as_ref(), xml.replace("UTF-16", "UTF-8"));
302            assert_eq!(
303                decode_xml_octets(&bytes, 1 << 20).expect("native split pair is valid"),
304                decoded.as_ref()
305            );
306        }
307    }
308
309    #[test]
310    #[cfg(feature = "xmldsig")]
311    fn scoped_transcoding_keeps_stateful_escape_sequences_across_chunks() {
312        // A boundary inside ISO-2022-JP's escape state must neither reset the
313        // decoder nor mistake an input-chunk EOF for the complete entity EOF.
314        use structured_asn1::memory::{Memory, MemoryAccount};
315        let xml = format!(
316            "<?xml version='1.0' encoding='ISO-2022-JP'?><r>{}</r>",
317            "aあbい".repeat(4096)
318        );
319        let (bytes, _, malformed) = encoding_rs::ISO_2022_JP.encode(&xml);
320        assert!(!malformed);
321        let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 24)));
322        let decoded = decode_xml_octets_in(&bytes, 1 << 20, &memory).expect("valid stateful XML");
323        assert_eq!(decoded.as_ref(), xml.replace("ISO-2022-JP", "UTF-8"));
324        assert_eq!(
325            decode_xml_octets(&bytes, 1 << 20).expect("same native decoder"),
326            decoded.as_ref()
327        );
328        drop(decoded);
329        assert_eq!(memory.used(), 0);
330    }
331
332    #[test]
333    #[cfg(feature = "xmldsig")]
334    fn scoped_transcoding_releases_partial_output_on_a_malformed_tail() {
335        // A decoder that has already produced several output buffers must
336        // preserve the strict byte error and release every retained byte.
337        use structured_asn1::memory::{Memory, MemoryAccount};
338        let xml = format!(
339            "<?xml version='1.0' encoding='UTF-16'?><r>{}",
340            "x".repeat(8192)
341        );
342        let mut bytes = utf16_bytes(&xml, true, true);
343        bytes.extend_from_slice(&0xd800_u16.to_le_bytes());
344        let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 24)));
345        assert!(matches!(
346            decode_xml_octets_in(&bytes, 1 << 20, &memory),
347            Err(DecodeError::Encoding(XmlEncodingError::InvalidBytes(
348                "UTF-16LE"
349            )))
350        ));
351        assert_eq!(memory.used(), 0);
352    }
353
354    fn utf16_bytes(xml: &str, little_endian: bool, bom: bool) -> Vec<u8> {
355        let mut bytes = if bom {
356            if little_endian {
357                vec![0xff, 0xfe]
358            } else {
359                vec![0xfe, 0xff]
360            }
361        } else {
362            Vec::new()
363        };
364        bytes.extend(xml.encode_utf16().flat_map(|value| {
365            if little_endian {
366                value.to_le_bytes()
367            } else {
368                value.to_be_bytes()
369            }
370        }));
371        bytes
372    }
373
374    #[test]
375    fn transcoding_normalizes_non_utf8_declarations() {
376        for bytes in [
377            utf16_bytes(
378                "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root/>",
379                true,
380                true,
381            ),
382            b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><root>caf\xe9</root>".to_vec(),
383        ] {
384            let decoded =
385                decode_xml_octets(&bytes, 1_024).expect("declared XML encoding must decode");
386            assert!(decoded.contains("encoding=\"UTF-8\"") || decoded.contains("encoding='UTF-8'"));
387        }
388    }
389
390    #[test]
391    fn declared_single_byte_xml_is_transcoded_strictly() {
392        // XML permits declared encodings beyond its mandatory UTF-8/UTF-16 baseline.
393        let latin1 = b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><root>caf\xe9</root>";
394        let decoded = decode_xml_octets(latin1, 1_024).expect("declared Latin-1 XML must decode");
395        assert!(decoded.contains("<root>café</root>"));
396        assert!(decoded.contains("encoding=\"UTF-8\""));
397    }
398
399    #[test]
400    fn byte_signatures_and_declarations_must_agree() {
401        let conflicting = utf16_bytes(
402            "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><root/>",
403            true,
404            true,
405        );
406        assert!(matches!(
407            decode_xml_octets(&conflicting, 1_024),
408            Err(XmlEncodingError::ConflictingEncoding(_))
409        ));
410
411        let matching_signature = utf16_bytes(
412            "<?xml version=\"1.0\" encoding=\"UTF-16BE\"?><root/>",
413            false,
414            true,
415        );
416        assert!(decode_xml_octets(&matching_signature, 1_024).is_ok());
417
418        let bomless_generic = utf16_bytes(
419            "<?xml version=\"1.0\" encoding=\"UTF-16\"?><root/>",
420            true,
421            false,
422        );
423        assert!(matches!(
424            decode_xml_octets(&bomless_generic, 1_024),
425            Err(XmlEncodingError::MissingUtf16ByteOrder)
426        ));
427    }
428
429    #[test]
430    fn utf8_is_borrowed_and_malformed_bytes_are_rejected() {
431        let xml = b"<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>";
432        assert!(matches!(
433            decode_xml_octets(xml, xml.len()),
434            Ok(Cow::Borrowed(_))
435        ));
436        assert!(decode_xml_octets(b"<root>\xff</root>", 1_024).is_err());
437    }
438
439    #[test]
440    fn declaration_detection_ignores_other_processing_instructions() {
441        for xml in [
442            r#"<?xml-stylesheet encoding="ISO-8859-1"?><root/>"#,
443            r#"<root data-encoding="ISO-8859-1"/>"#,
444        ] {
445            decode_xml_octets(xml.as_bytes(), 1_024)
446                .expect("ordinary content does not select encoding");
447        }
448    }
449
450    #[test]
451    fn transcoding_stops_at_the_decoded_document_limit() {
452        let bytes = utf16_bytes("<?xml version=\"1.0\"?><root>expanded</root>", true, true);
453        assert!(matches!(
454            decode_xml_octets(&bytes, 16),
455            Err(XmlEncodingError::DecodedLimit { maximum: 16, .. })
456        ));
457    }
458}