oxml 0.0.11

A pure Rust XML toolkit with zero unsafe code: parsing, an ergonomic tree, and XPath
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2026 oxml. All rights reserved.

//! Resource bounds for parsing untrusted input.
//!
//! Every limit here exists because some parser shipped without it and
//! was exploited. They are not tuning knobs; they are the difference
//! between a parse error and a denial of service.
//!
//! The defaults are chosen to accept every document a human would write
//! and reject the shapes that only appear in attacks. A caller
//! processing documents they produced themselves can raise them; a
//! caller reading from the network should generally lower them.
//!
//! # Why bounds, and not just "safe Rust"
//!
//! `#![forbid(unsafe_code)]` prevents memory-unsafety. It does not
//! prevent a parser from allocating gigabytes, recursing until the
//! stack is gone, or spending ten minutes in a quadratic loop. Two
//! HIGH-severity advisories against another Rust XML crate in 2026
//! (RUSTSEC-2026-0194 and -0195) were exactly this: an O(N²)
//! duplicate-attribute scan and an unbounded namespace allocation, both
//! in entirely safe code.

/// Which edition of XML 1.0 to apply.
///
/// The 5th edition replaced the enumerated character classes of
/// Appendix B with a small set of broad ranges. The two **disagree** —
/// they are not a superset relation — so a name legal under one may be
/// illegal under the other. Nearly all documents are unaffected; those
/// that are not are exactly what the W3C suite's 309 edition-specific
/// tests probe.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Edition {
    /// XML 1.0 4th edition and earlier: `Letter | '_' | ':'`, where
    /// `Letter` is Appendix B's `BaseChar | Ideographic`.
    Fourth,
    /// XML 1.0 5th edition, the current one, and XML 1.1.
    #[default]
    Fifth,
}

/// Resource bounds applied while parsing.
///
/// Construct with [`Limits::default`] and adjust, or start from
/// [`Limits::permissive`] / [`Limits::strict`].
///
/// This type is `#[non_exhaustive]`: new limits will be added as new
/// features land, and adding one must not be a breaking change.
/// Construct it from `default()` rather than with a struct literal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Limits {
    /// Which edition's name rules to apply.
    ///
    /// Default: [`Edition::Fifth`].
    pub edition: Edition,

    /// Maximum element nesting depth.
    ///
    /// Parsing descends one stack frame per open element, so an
    /// unbounded document exhausts the stack — and a stack overflow
    /// aborts the process rather than unwinding, so no caller can
    /// catch it.
    ///
    /// Default: `256`. For comparison, Woodstox defaults to 1000 and the
    /// JDK's `maxElementDepth` to 100.
    ///
    /// # This limit is bounded by the stack, not by policy
    ///
    /// Parsing is recursive descent, so each open element costs a stack
    /// frame. Measured on this parser:
    ///
    /// | Build | Stack | Max depth | Bytes/frame |
    /// |---|---|---|---|
    /// | release | 2 MiB | 1,937 | ~1,086 |
    /// | release | 8 MiB | 7,720 | ~1,086 |
    /// | debug | 2 MiB | **280** | ~7,489 |
    /// | debug | 8 MiB | 1,121 | ~7,483 |
    ///
    /// A debug build on a 2 MiB thread — which is what `cargo test`
    /// gives you — survives only about 280 levels. That is why the
    /// default is 256 and why [`Limits::permissive`] does **not** raise
    /// it: exceeding the real ceiling aborts the process rather than
    /// returning an error, so a generous-looking value here would
    /// defeat the protection it appears to provide.
    ///
    /// To go deeper, run the parse on a thread with a larger stack and
    /// budget roughly 7.5 KiB per level for a debug build:
    ///
    /// ```no_run
    /// # use oxml::{Limits, parse_with};
    /// let mut limits = Limits::default();
    /// limits.max_depth = 2_000;
    /// std::thread::Builder::new()
    ///     .stack_size(32 * 1024 * 1024)
    ///     .spawn(move || parse_with("<a/>", limits))
    ///     .expect("spawn");
    /// ```
    pub max_depth: usize,

    /// Maximum number of attributes on a single element.
    ///
    /// Guards the duplicate-attribute check, which must compare each
    /// new name against those already seen.
    ///
    /// Default: 1000, matching Woodstox's
    /// `maxAttributesPerElement`.
    pub max_attributes_per_element: usize,

    /// Maximum length in bytes of a single attribute value.
    ///
    /// Default: `524_288`, matching Woodstox's `maxAttributeSize`.
    pub max_attribute_size: usize,

    /// Maximum length in bytes of an element, attribute, PI or
    /// DTD-declared name.
    ///
    /// Default: `8192`. The JDK's `maxXMLNameLimit` is 1000, which
    /// the W3C suite shows to be too low: `ibm85v01` is a valid
    /// document whose processing-instruction target enumerates every
    /// legal `BaseChar` in a single 4,250-byte name.
    pub max_name_length: usize,

    /// Maximum number of nodes in the resulting document, if any.
    ///
    /// The most direct bound on memory: the arena cannot exceed this
    /// many entries. `None` means unbounded.
    ///
    /// Default: `None`.
    pub max_nodes: Option<usize>,

    /// Maximum length in bytes of a single text or CDATA node, if any.
    ///
    /// Default: `None`.
    pub max_text_length: Option<usize>,

    /// Maximum source bytes, before decoding or copying the input.
    ///
    /// Also applies to incremental readers across all feeds. Default:
    /// unbounded; strict: `8 MiB`.
    pub max_input_bytes: Option<usize>,

    /// Maximum UTF-8 bytes in the decoded document, before its copy.
    ///
    /// External content is checked before normalization too. Default:
    /// unbounded; strict: `8 MiB`.
    pub max_document_bytes: Option<usize>,

    /// Maximum nesting depth of an `XPath` expression.
    ///
    /// An expression is untrusted input in every front end of this
    /// crate: the CLI takes one from a shell, the MCP server from a
    /// model, the WASM bindings from JavaScript. Compilation is
    /// recursive descent, so `((((…))))` exhausts the stack.
    ///
    /// Counts nested parentheses, predicates and function arguments;
    /// an expression nested more levels than this is a compile error.
    /// Applied by `XPath::compile_with_limits`; `XPath::compile` uses
    /// the default.
    ///
    /// Default: `256`.
    pub max_xpath_depth: usize,

    /// Maximum nesting depth of general entity expansion.
    ///
    /// The billion-laughs bound. Ten nested entities each referring to
    /// the previous ten times is 10^10 characters from a few hundred
    /// bytes of input.
    ///
    /// Default: `10`, matching roxmltree and the JDK's posture.
    pub max_entity_depth: usize,

    /// Maximum total characters produced by expanding entities in one
    /// document.
    ///
    /// Depth alone does not bound the quadratic-blowup variant, where a
    /// single large entity is referenced many times at depth one.
    ///
    /// Default: `10_000_000`.
    pub max_entity_expansion: usize,

    /// Maximum number of operators and steps in one `XPath` expression.
    ///
    /// Bounds compilation work independently of nesting. The JDK
    /// enforces an analogous `xpathTotalOpLimit`; no Rust `XPath`
    /// implementation does.
    ///
    /// Counts every binary operator, unary minus, location step,
    /// predicate and function call; one more than this is a compile
    /// error. Applied by `XPath::compile_with_limits`;
    /// `XPath::compile` uses the default.
    ///
    /// Default: `10_000`.
    pub max_xpath_operators: usize,

    /// Maximum bytes of an expression, checked before parsing it.
    /// Default: `1 MiB`; strict: `64 KiB`; permissive: unbounded.
    pub max_xpath_bytes: Option<usize>,

    /// Maximum syntax units (operands and operators) in an expression.
    /// Bounds AST growth even for calls with many literal arguments.
    /// Default: `40_000`; strict: `4_000`.
    pub max_xpath_nodes: usize,

    /// Maximum arguments in one function call, before parsing another.
    /// Default: `10_000`; strict: `1_000`.
    pub max_xpath_arguments: usize,

    /// Maximum total bytes of document type declaration text the DTD
    /// parser will read, if any.
    ///
    /// The internal subset, an external subset the caller supplied,
    /// and every external parameter entity fetched through
    /// [`ExternalSource`] all count towards one budget. Declarations
    /// parse in linear time, so this is a ceiling on memory rather
    /// than on time: an external source can hand back a subset of any
    /// size, and nothing else bounds how much of it is held.
    ///
    /// `None` means unbounded. Default: `Some(16 MiB)`, which no DTD
    /// in the W3C conformance suite approaches. Woodstox and the JDK
    /// have no direct equivalent; the JDK's `totalEntitySizeLimit`
    /// (50,000,000) is the nearest.
    ///
    /// [`ExternalSource`]: crate::external::ExternalSource
    pub max_dtd_bytes: Option<usize>,

    /// Maximum nesting of DTD content groups and included sections.
    ///
    /// Default: `64`; strict: `32`. Content groups use a bounded
    /// explicit stack. Included sections still use recursive descent.
    pub max_dtd_depth: usize,

    /// Maximum total bytes of replacement text produced by expanding
    /// parameter entities in one document.
    ///
    /// The parameter-entity form of the billion-laughs bound, charged
    /// the way [`Limits::max_entity_expansion`] is. Depth alone does
    /// not catch the width variant: three entities of a hundred
    /// references each produce ten thousand copies of the innermost
    /// one from under a kilobyte of declarations, three levels deep.
    ///
    /// Default: `10_000_000`, the same as `max_entity_expansion`.
    pub max_pe_expansion: usize,

    /// Maximum bytes of a single construct the streaming reader will
    /// buffer while waiting for its end, if any.
    ///
    /// [`Reader::from_reader`] and `AsyncReader` keep only the
    /// construct being read: a start tag up to its `>`, a comment up
    /// to its `-->`, a run of character data up to the next `<`. The
    /// scanner is never handed a construct that is not whole, so
    /// neither [`Limits::max_text_length`] nor
    /// [`Limits::max_attribute_size`] can fire until the construct is
    /// complete -- and a source that never closes one would grow the
    /// buffer without bound. This is the cap on that buffer; a
    /// construct that exceeds it is refused with
    /// [`ErrorKind::ConstructTooLarge`].
    ///
    /// Only the incremental readers apply it. [`Reader::new`] and
    /// [`crate::parse`] are handed the whole document, so there is
    /// nothing left to bound.
    ///
    /// `None` means unbounded. Default: `Some(64 MiB)`. Woodstox has
    /// no equivalent because it reports a text node in pieces; this
    /// reader reports a run whole so the events match the tree.
    ///
    /// [`Reader::from_reader`]: crate::stream::Reader::from_reader
    /// [`Reader::new`]: crate::stream::Reader::new
    /// [`ErrorKind::ConstructTooLarge`]: crate::ErrorKind::ConstructTooLarge
    pub max_construct_bytes: Option<usize>,
}

impl Default for Limits {
    fn default() -> Self {
        Self {
            edition: Edition::Fifth,
            max_depth: 256,
            max_attributes_per_element: 1_000,
            max_attribute_size: 524_288,
            max_name_length: 8_192,
            max_nodes: None,
            max_text_length: None,
            max_input_bytes: None,
            max_document_bytes: None,
            max_entity_depth: 10,
            max_entity_expansion: 10_000_000,
            max_xpath_depth: 256,
            max_xpath_operators: 10_000,
            max_xpath_bytes: Some(1024 * 1024),
            max_xpath_nodes: 40_000,
            max_xpath_arguments: 10_000,
            max_dtd_bytes: Some(16 * 1024 * 1024),
            max_dtd_depth: 64,
            max_pe_expansion: 10_000_000,
            max_construct_bytes: Some(64 * 1024 * 1024),
        }
    }
}

impl Limits {
    pub(crate) fn check_input(&self, len: usize) -> crate::Result<()> {
        check_bytes(
            len,
            self.max_input_bytes,
            crate::ErrorKind::InputTooLarge,
            0,
        )
    }

    pub(crate) fn check_document(
        &self,
        len: usize,
        at: usize,
    ) -> crate::Result<()> {
        check_bytes(
            len,
            self.max_document_bytes,
            crate::ErrorKind::DocumentTooLarge,
            at,
        )
    }

    /// Check an arena reservation before any of its nodes are allocated.
    pub(crate) fn check_nodes(
        &self,
        current: usize,
        additional: usize,
        at: usize,
    ) -> crate::Result<()> {
        let total = current.checked_add(additional);
        if total.is_none()
            || self
                .max_nodes
                .is_some_and(|max| total.unwrap_or(usize::MAX) > max)
        {
            return Err(crate::Error::new(crate::ErrorKind::TooManyNodes, at));
        }
        Ok(())
    }

    /// Bounds suitable for input you produced yourself.
    ///
    /// Raises every cap substantially and removes the optional ones.
    /// Do not use this on input arriving from a network.
    ///
    /// **`max_depth` is deliberately left at the default.** It is
    /// bounded by the thread's stack rather than by policy, and
    /// exceeding the real ceiling aborts the process instead of
    /// returning an error — so raising it here would hand out a
    /// crash, not permissiveness. See [`Limits::max_depth`] for the
    /// measured ceilings and how to raise it safely.
    #[must_use]
    pub fn permissive() -> Self {
        Self {
            edition: Edition::Fifth,
            max_depth: Self::default().max_depth,
            max_attributes_per_element: 100_000,
            max_attribute_size: 64 * 1024 * 1024,
            max_name_length: 1024 * 1024,
            max_nodes: None,
            max_text_length: None,
            max_input_bytes: None,
            max_document_bytes: None,
            max_entity_depth: 40,
            max_entity_expansion: 1_000_000_000,
            max_xpath_depth: 1_000,
            max_xpath_operators: 1_000_000,
            max_xpath_bytes: None,
            max_xpath_nodes: 4_000_000,
            max_xpath_arguments: 1_000_000,
            max_dtd_bytes: None,
            max_dtd_depth: 64,
            max_pe_expansion: 1_000_000_000,
            max_construct_bytes: None,
        }
    }

    /// Bounds suitable for small documents from an untrusted source.
    ///
    /// Finite source, decoded-byte, structural and entity budgets.
    /// Will reject some legitimate large documents. Callers still
    /// need concurrency limits and a separate `XPath` evaluation policy.
    #[must_use]
    pub fn strict() -> Self {
        Self {
            edition: Edition::Fifth,
            max_depth: 64,
            max_attributes_per_element: 64,
            max_attribute_size: 8 * 1024,
            max_name_length: 256,
            max_nodes: Some(100_000),
            max_text_length: Some(1024 * 1024),
            max_input_bytes: Some(8 * 1024 * 1024),
            max_document_bytes: Some(8 * 1024 * 1024),
            max_entity_depth: 4,
            max_entity_expansion: 100_000,
            max_xpath_depth: 32,
            max_xpath_operators: 1_000,
            max_xpath_bytes: Some(64 * 1024),
            max_xpath_nodes: 4_000,
            max_xpath_arguments: 1_000,
            max_dtd_bytes: Some(64 * 1024),
            max_dtd_depth: 32,
            max_pe_expansion: 100_000,
            max_construct_bytes: Some(1024 * 1024),
        }
    }
}

/// Refuse a byte reservation before allocating its storage.
pub(crate) fn check_bytes(
    len: usize,
    limit: Option<usize>,
    kind: crate::ErrorKind,
    at: usize,
) -> crate::Result<()> {
    if limit.is_some_and(|max| len > max) {
        return Err(crate::Error::new(kind, at));
    }
    Ok(())
}