Skip to main content

pdk_xml_validator_lib/
lib.rs

1// Copyright (c) 2026, Salesforce, Inc.,
2// All rights reserved.
3// For full license text, see the LICENSE.txt file
4
5//! # PDK XML Validator Library
6//!
7//! Provides XML validation functionality with configurable structural constraints,
8//! validating XML payloads incrementally from the request body stream.
9//!
10//! ## Highlights
11//!
12//! - Stream-first validation API for policy usage
13//! - Configurable limits for depth, attributes, children, text and comments
14//!
15//! ## Primary types
16//!
17//! - [`XmlValidatorBuilder`]: builder to configure validation constraints
18//! - [`XmlValidator`]: validator instance used to validate XML payload streams
19//! - [`XmlValidator::validate`]: validate from an async byte source
20//! - [`XmlValidator::validate_stream`]: validate a PDK body stream
21//! - [`XmlValidationError`]: error type for structural violations and malformed XML
22
23use classy::hl::{BodyStream, BodyStreamAsyncReader};
24use quick_xml::events::BytesStart;
25use quick_xml::{events::Event, reader::NsReader};
26use std::error::Error;
27use thiserror::Error;
28use tokio::io::{AsyncRead, BufReader};
29use tokio_util::compat::FuturesAsyncReadCompatExt;
30
31/// Errors that can occur during XML validation.
32#[non_exhaustive]
33#[derive(Debug, Error)]
34pub enum XmlValidationError {
35    /// The maximum allowed depth of the XML document has been exceeded.
36    #[error("Node depth count has been exceeded. Maximum allowed is: {0}")]
37    MaxDepthExceeded(usize),
38    /// The maximum allowed number of attributes in an element has been exceeded.
39    #[error("Attribute count has been exceeded. Maximum allowed is: {0}")]
40    MaxAttributeCountExceeded(usize),
41    /// The maximum allowed length of the text in an element has been exceeded.
42    #[error("Text length has been exceeded. Maximum allowed is: {0}")]
43    MaxTextLengthExceeded(usize),
44    /// The maximum allowed length of the comment in an element has been exceeded.
45    #[error("Comment length has been exceeded. Maximum allowed is: {0}")]
46    MaxCommentLengthExceeded(usize),
47    /// The maximum allowed length of the attribute value has been exceeded.
48    #[error("Attribute value length has been exceeded. Maximum allowed is: {0}")]
49    MaxAttributeLengthExceeded(usize),
50    /// The maximum allowed number of children in an element has been exceeded.
51    #[error("Child count has been exceeded. Maximum allowed is: {0}")]
52    MaxChildCountExceeded(usize),
53    /// The attribute cannot be read.
54    #[error("Can not read attributes: {0}")]
55    AttributeError(Box<dyn Error>),
56    /// The XML document is malformed.
57    #[error("Malformed document: {0}")]
58    MalformedDocument(Box<dyn Error>),
59    /// The XML document structure is invalid.
60    #[error("XML Validation Error: {0}")]
61    InvalidStructure(String),
62}
63
64#[derive(Debug, Default)]
65pub struct XmlValidatorBuilder {
66    max_depth: Option<usize>,
67    max_attribute_count: Option<usize>,
68    max_child_count: Option<usize>,
69    max_text_length: Option<usize>,
70    max_attribute_length: Option<usize>,
71    max_comment_length: Option<usize>,
72}
73
74impl XmlValidatorBuilder {
75    /// Creates a new builder without restrictions.
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Sets the maximum allowed depth of the XML document.
81    pub fn with_max_depth(mut self, depth: usize) -> Self {
82        self.max_depth = Some(depth);
83        self
84    }
85
86    /// Sets the maximum allowed number of attributes in an element.
87    pub fn with_max_attribute_count(mut self, count: usize) -> Self {
88        self.max_attribute_count = Some(count);
89        self
90    }
91
92    /// Sets the maximum allowed number of children in an element.
93    pub fn with_max_child_count(mut self, count: usize) -> Self {
94        self.max_child_count = Some(count);
95        self
96    }
97
98    /// Sets the maximum allowed length of the text in an element.
99    pub fn with_max_text_length(mut self, length: usize) -> Self {
100        self.max_text_length = Some(length);
101        self
102    }
103
104    /// Sets the maximum allowed length of the attribute value.
105    pub fn with_max_attribute_length(mut self, length: usize) -> Self {
106        self.max_attribute_length = Some(length);
107        self
108    }
109
110    /// Sets the maximum allowed length of the comment in an element.
111    pub fn with_max_comment_length(mut self, length: usize) -> Self {
112        self.max_comment_length = Some(length);
113        self
114    }
115
116    /// Builds the validation instance with the specified configuration.
117    pub fn build(self) -> XmlValidator {
118        XmlValidator {
119            max_depth: self.max_depth,
120            max_attribute_count: self.max_attribute_count,
121            max_child_count: self.max_child_count,
122            max_text_length: self.max_text_length,
123            max_attribute_length: self.max_attribute_length,
124            max_comment_length: self.max_comment_length,
125        }
126    }
127}
128
129/// XML validator with configurable constraints.
130pub struct XmlValidator {
131    /// Maximum allowed depth of the XML document.
132    max_depth: Option<usize>,
133    /// Maximum allowed number of attributes in an element.
134    max_attribute_count: Option<usize>,
135    /// Maximum allowed number of children in an element.
136    max_child_count: Option<usize>,
137    /// Maximum allowed length of the text in an element.
138    max_text_length: Option<usize>,
139    /// Maximum allowed length of the attribute value.
140    max_attribute_length: Option<usize>,
141    /// Maximum allowed length of the comment in an element.
142    max_comment_length: Option<usize>,
143}
144
145struct ElementInfo {
146    child_count: usize,
147}
148
149impl XmlValidator {
150    /// Validates XML from a PDK body stream.
151    pub async fn validate_stream(&self, stream: BodyStream<'_>) -> Result<(), XmlValidationError> {
152        self.validate(BodyStreamAsyncReader::new(stream).compat())
153            .await
154    }
155
156    /// Validates XML from an async byte source.
157    pub async fn validate(&self, reader: impl AsyncRead + Unpin) -> Result<(), XmlValidationError> {
158        let reader = BufReader::new(reader);
159        let mut buf = Vec::with_capacity(1024);
160        let mut xml_reader = NsReader::from_reader(reader);
161        let mut depth = 0usize;
162        let mut element_stack: Vec<ElementInfo> = Vec::new();
163        // Decoded length of the current text run. quick-xml 0.41 splits
164        // entity-bearing text into Text + GeneralRef + Text events, so we must
165        // accumulate the reassembled run and check the total against
166        // `max_text_length` — checking each fragment in isolation (and letting
167        // GeneralRef count as 0) would let entity-heavy text slip past a limit
168        // that quick-xml 0.37 rejected. Reset on any structural boundary.
169        let mut text_run_len = 0usize;
170
171        loop {
172            let event = xml_reader.read_event_into_async(&mut buf).await;
173            match event {
174                Ok(ref event @ Event::Start(ref e)) | Ok(ref event @ Event::Empty(ref e)) => {
175                    // New element boundary: text limits are per-element.
176                    text_run_len = 0;
177                    depth += 1;
178
179                    if let Some(max_depth) = self.max_depth {
180                        if depth > max_depth {
181                            return Err(XmlValidationError::MaxDepthExceeded(max_depth));
182                        }
183                    }
184
185                    if let Event::Empty(_) = event {
186                        depth -= 1;
187                    }
188
189                    if let Some(max_attr_count) = self.max_attribute_count {
190                        if check_attribute_count(e, max_attr_count)
191                            .map_err(|err| XmlValidationError::AttributeError(Box::new(err)))?
192                        {
193                            return Err(XmlValidationError::MaxAttributeCountExceeded(
194                                max_attr_count,
195                            ));
196                        }
197                    }
198
199                    if let Some(max_attr_len) = self.max_attribute_length {
200                        if check_attribute_length(e, max_attr_len)
201                            .map_err(|err| XmlValidationError::AttributeError(Box::new(err)))?
202                        {
203                            return Err(XmlValidationError::MaxAttributeLengthExceeded(
204                                max_attr_len,
205                            ));
206                        }
207                    }
208
209                    if !element_stack.is_empty() {
210                        if let Some(parent) = element_stack.last_mut() {
211                            parent.child_count += 1;
212                            if let Some(max_child_count) = self.max_child_count {
213                                if parent.child_count > max_child_count {
214                                    return Err(XmlValidationError::MaxChildCountExceeded(
215                                        max_child_count,
216                                    ));
217                                }
218                            }
219                        }
220                    }
221
222                    if let Event::Start(_) = event {
223                        element_stack.push(ElementInfo { child_count: 0 });
224                    }
225                }
226                Ok(Event::End(_)) => {
227                    text_run_len = 0;
228                    element_stack.pop();
229                    depth -= 1;
230                }
231                Ok(Event::Eof) => {
232                    if !element_stack.is_empty() {
233                        return Err(XmlValidationError::InvalidStructure(
234                            "XML document structures must start and end within the same entity."
235                                .to_string(),
236                        ));
237                    }
238                    return Ok(());
239                }
240                Ok(Event::Text(t)) => {
241                    let is_only_whitespace = t.as_ref().iter().all(|&b| b.is_ascii_whitespace());
242                    if is_only_whitespace {
243                        continue;
244                    }
245
246                    if element_stack.is_empty() {
247                        return Err(XmlValidationError::InvalidStructure(
248                            "Text found outside of an element".to_string(),
249                        ));
250                    }
251
252                    if let Some(max_text_len) = self.max_text_length {
253                        text_run_len = text_run_len.saturating_add(unescape_event(&t)?.len());
254                        if text_run_len > max_text_len {
255                            return Err(XmlValidationError::MaxTextLengthExceeded(max_text_len));
256                        }
257                    }
258                }
259                Ok(Event::GeneralRef(r)) => {
260                    // quick-xml 0.41 emits entity references (`&lt;`, `&#60;`, …)
261                    // as their own event, splitting a text run. Count the
262                    // expanded length toward the current run so `max_text_length`
263                    // sees the same reassembled text quick-xml 0.37 did.
264                    if let Some(max_text_len) = self.max_text_length {
265                        text_run_len = text_run_len.saturating_add(resolve_general_ref(&r)?.len());
266                        if text_run_len > max_text_len {
267                            return Err(XmlValidationError::MaxTextLengthExceeded(max_text_len));
268                        }
269                    }
270                }
271                Ok(Event::Comment(t)) => {
272                    if let Some(max_comment_len) = self.max_comment_length {
273                        let comment = unescape_event(&t)?;
274                        if comment.len() > max_comment_len {
275                            return Err(XmlValidationError::MaxCommentLengthExceeded(
276                                max_comment_len,
277                            ));
278                        }
279                    }
280                }
281                Err(e) => {
282                    return Err(XmlValidationError::MalformedDocument(Box::new(e)));
283                }
284                Ok(_) => {}
285            }
286            buf.clear();
287        }
288    }
289}
290
291/// Decodes an event's raw bytes and resolves XML entity escapes, mapping any
292/// failure to a [`XmlValidationError::MalformedDocument`].
293fn unescape_event(t: &quick_xml::events::BytesText<'_>) -> Result<String, XmlValidationError> {
294    let decoded = t
295        .decode()
296        .map_err(|err| XmlValidationError::MalformedDocument(Box::new(err)))?;
297    let unescaped = quick_xml::escape::unescape(&decoded)
298        .map_err(|err| XmlValidationError::MalformedDocument(Box::new(err)))?;
299    Ok(unescaped.into_owned())
300}
301
302/// Resolves an entity reference event (the `name` between `&` and `;`) to its
303/// replacement text, so its expanded length can be counted toward the text-run
304/// limit. Reconstructs the `&name;` form and reuses the same unescape path as
305/// [`unescape_event`]; an unknown entity surfaces as
306/// [`XmlValidationError::MalformedDocument`], matching quick-xml 0.37 behavior.
307fn resolve_general_ref(r: &quick_xml::events::BytesRef<'_>) -> Result<String, XmlValidationError> {
308    let name = r
309        .decode()
310        .map_err(|err| XmlValidationError::MalformedDocument(Box::new(err)))?;
311    let reconstructed = format!("&{name};");
312    let unescaped = quick_xml::escape::unescape(&reconstructed)
313        .map_err(|err| XmlValidationError::MalformedDocument(Box::new(err)))?;
314    Ok(unescaped.into_owned())
315}
316
317fn check_attribute_count(
318    e: &BytesStart<'_>,
319    max_attr_count: usize,
320) -> Result<bool, quick_xml::Error> {
321    let mut count = 0usize;
322    for attr in e.attributes() {
323        let attr_name = String::from_utf8_lossy(attr?.key.0);
324        if !attr_name.starts_with("xmlns") {
325            count += 1;
326        }
327        if count > max_attr_count {
328            return Ok(true);
329        }
330    }
331    Ok(false)
332}
333
334fn check_attribute_length(e: &BytesStart<'_>, max_len: usize) -> Result<bool, quick_xml::Error> {
335    for attr in e.attributes() {
336        let attr = attr?;
337        // Decode bytes to UTF-8 string
338        let decoded = std::str::from_utf8(attr.value.as_ref())
339            .map_err(quick_xml::encoding::EncodingError::Utf8)?;
340        // Unescape XML entities only (no normalization)
341        let unescaped = quick_xml::escape::unescape(decoded)?;
342        if unescaped.len() > max_len {
343            return Ok(true);
344        }
345    }
346    Ok(false)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use quick_xml::events::Event;
353    use quick_xml::reader::Reader;
354
355    fn build_validator() -> XmlValidator {
356        XmlValidatorBuilder::new()
357            .with_max_depth(3)
358            .with_max_attribute_count(2)
359            .with_max_child_count(2)
360            .with_max_text_length(10)
361            .with_max_attribute_length(5)
362            .with_max_comment_length(10)
363            .build()
364    }
365
366    #[tokio::test]
367    async fn validate_accepts_valid_xml_stream() {
368        let validator = build_validator();
369
370        let xml = r#"<root><child attr="ok">text</child><child2/></root>"#;
371        let result = validator.validate(xml.as_bytes()).await;
372        assert!(result.is_ok());
373    }
374
375    #[tokio::test]
376    async fn validate_accepts_vec_buffer_as_slice() {
377        let validator = XmlValidatorBuilder::new().build();
378        let bytes = b"<root><n/></root>".to_vec();
379        let result = validator.validate(bytes.as_slice()).await;
380        assert!(result.is_ok());
381    }
382
383    #[tokio::test]
384    async fn validate_vec_single_byte_slice_is_rejected() {
385        let validator = XmlValidatorBuilder::new().build();
386        let bytes = vec![0u8];
387        let result = validator.validate(bytes.as_slice()).await;
388        assert!(result.is_err());
389    }
390
391    #[tokio::test]
392    async fn validate_rejects_depth_over_limit() {
393        let validator = XmlValidatorBuilder::new().with_max_depth(1).build();
394
395        let xml = r#"<root><child><grandchild/></child></root>"#;
396        let result = validator.validate(xml.as_bytes()).await;
397        assert!(matches!(
398            result,
399            Err(XmlValidationError::MaxDepthExceeded(1))
400        ));
401    }
402
403    #[tokio::test]
404    async fn validate_rejects_attribute_count_over_limit() {
405        let validator = build_validator();
406        let xml = r#"<root a="1" b="2" c="3"/>"#;
407        let result = validator.validate(xml.as_bytes()).await;
408        assert!(matches!(
409            result,
410            Err(XmlValidationError::MaxAttributeCountExceeded(2))
411        ));
412    }
413
414    #[tokio::test]
415    async fn validate_rejects_text_outside_element() {
416        let validator = build_validator();
417        let xml = r#"text<root/>"#;
418        let result = validator.validate(xml.as_bytes()).await;
419        assert!(matches!(
420            result,
421            Err(XmlValidationError::InvalidStructure(_))
422        ));
423    }
424
425    #[tokio::test]
426    async fn validate_rejects_attribute_length_over_limit() {
427        let validator = build_validator();
428        let xml = r#"<root attr="123456"/>"#;
429        let result = validator.validate(xml.as_bytes()).await;
430        assert!(matches!(
431            result,
432            Err(XmlValidationError::MaxAttributeLengthExceeded(5))
433        ));
434    }
435
436    #[tokio::test]
437    async fn validate_rejects_child_count_over_limit() {
438        let validator = XmlValidatorBuilder::new().with_max_child_count(1).build();
439        let xml = r#"<root><c1/><c2/></root>"#;
440        let result = validator.validate(xml.as_bytes()).await;
441        assert!(matches!(
442            result,
443            Err(XmlValidationError::MaxChildCountExceeded(1))
444        ));
445    }
446
447    #[tokio::test]
448    async fn validate_rejects_text_length_over_limit() {
449        let validator = XmlValidatorBuilder::new().with_max_text_length(3).build();
450        let xml = r#"<root>abcd</root>"#;
451        let result = validator.validate(xml.as_bytes()).await;
452        assert!(matches!(
453            result,
454            Err(XmlValidationError::MaxTextLengthExceeded(3))
455        ));
456    }
457
458    #[tokio::test]
459    async fn validate_rejects_entity_text_length_over_limit() {
460        // quick-xml 0.41 splits this into Text + GeneralRef + Text events. The
461        // reassembled decoded text (`<<<<<<`, 6 chars) must be measured against
462        // the limit as a whole, not per-fragment (each `&lt;` expands to a
463        // single `<`, and a naive per-fragment/GeneralRef=0 check would let it
464        // through).
465        let validator = XmlValidatorBuilder::new().with_max_text_length(3).build();
466        let xml = r#"<root>&lt;&lt;&lt;&lt;&lt;&lt;</root>"#;
467        let result = validator.validate(xml.as_bytes()).await;
468        assert!(matches!(
469            result,
470            Err(XmlValidationError::MaxTextLengthExceeded(3))
471        ));
472    }
473
474    #[tokio::test]
475    async fn validate_accepts_entity_text_within_limit() {
476        // Same splitting, but the reassembled `<<<` (3 chars) is within the
477        // limit, so it must be accepted.
478        let validator = XmlValidatorBuilder::new().with_max_text_length(3).build();
479        let xml = r#"<root>&lt;&lt;&lt;</root>"#;
480        let result = validator.validate(xml.as_bytes()).await;
481        assert!(result.is_ok(), "unexpected: {:?}", result);
482    }
483
484    #[tokio::test]
485    async fn validate_resets_text_run_between_elements() {
486        // Text limits are per-element: two sibling elements each under the limit
487        // must pass even though their combined text exceeds it.
488        let validator = XmlValidatorBuilder::new().with_max_text_length(3).build();
489        let xml = r#"<root><a>ab</a><b>cd</b></root>"#;
490        let result = validator.validate(xml.as_bytes()).await;
491        assert!(result.is_ok(), "unexpected: {:?}", result);
492    }
493
494    #[tokio::test]
495    async fn validate_rejects_comment_length_over_limit() {
496        let validator = XmlValidatorBuilder::new()
497            .with_max_comment_length(3)
498            .build();
499        let xml = r#"<root><!--abcd--></root>"#;
500        let result = validator.validate(xml.as_bytes()).await;
501        assert!(matches!(
502            result,
503            Err(XmlValidationError::MaxCommentLengthExceeded(3))
504        ));
505    }
506
507    #[tokio::test]
508    async fn validate_excludes_xmlns_from_attribute_count() {
509        let validator = XmlValidatorBuilder::new()
510            .with_max_attribute_count(1)
511            .build();
512        let xml = r#"<root xmlns="urn:test" xmlns:x="urn:test2" attr="1"/>"#;
513        let result = validator.validate(xml.as_bytes()).await;
514        assert!(result.is_ok());
515    }
516
517    #[tokio::test]
518    async fn validate_accepts_whitespace_outside_root() {
519        let validator = XmlValidatorBuilder::new().build();
520        let xml = "  \n\t<root/>";
521        let result = validator.validate(xml.as_bytes()).await;
522        assert!(result.is_ok());
523    }
524
525    #[tokio::test]
526    async fn validate_rejects_malformed_document() {
527        let validator = XmlValidatorBuilder::new().build();
528        let xml = r#"<root><child></root>"#;
529        let result = validator.validate(xml.as_bytes()).await;
530        assert!(matches!(
531            result,
532            Err(XmlValidationError::MalformedDocument(_))
533        ));
534    }
535
536    #[tokio::test]
537    async fn validate_rejects_unclosed_structure_at_eof() {
538        let validator = XmlValidatorBuilder::new().build();
539        let xml = r#"<root>"#;
540        let result = validator.validate(xml.as_bytes()).await;
541        assert!(matches!(
542            result,
543            Err(XmlValidationError::InvalidStructure(_))
544        ));
545    }
546
547    #[test]
548    fn check_attribute_helpers_cover_non_exceeded_paths() {
549        let mut count_reader = Reader::from_str(r#"<root xmlns="urn:test" attr="123"/>"#);
550        let mut count_buf = Vec::new();
551        let count_event = count_reader
552            .read_event_into(&mut count_buf)
553            .expect("must parse start");
554        let count_start = match count_event {
555            Event::Start(e) | Event::Empty(e) => e,
556            _ => panic!("expected start or empty event"),
557        };
558
559        let mut length_reader = Reader::from_str(r#"<root attr="123"/>"#);
560        let mut length_buf = Vec::new();
561        let length_event = length_reader
562            .read_event_into(&mut length_buf)
563            .expect("must parse start");
564        let length_start = match length_event {
565            Event::Start(e) | Event::Empty(e) => e,
566            _ => panic!("expected start or empty event"),
567        };
568
569        let count_exceeded =
570            check_attribute_count(&count_start, 1).expect("count check must succeed");
571        let len_exceeded =
572            check_attribute_length(&length_start, 3).expect("length check must succeed");
573        assert!(!count_exceeded);
574        assert!(!len_exceeded);
575    }
576}