Skip to main content

sup_xml_core/
options.rs

1#![forbid(unsafe_code)]  // see CONTRIBUTING.md § "Unsafe policy"
2
3use std::sync::Arc;
4
5use crate::encoding::Encoding;
6use crate::entity_resolver::EntityResolver;
7
8/// Security limits and feature flags for the XML parser.
9///
10/// Construct with `ParseOptions::default()` for safe defaults, then override
11/// individual fields as needed.  All external-loading features are **off** by
12/// default to prevent XXE and SSRF attacks.  Limits mirror libxml2's defaults
13/// where applicable.
14///
15/// # Example
16/// ```
17/// use sup_xml_core::{ParseOptions, parse_str};
18///
19/// let opts = ParseOptions {
20///     max_element_depth: 64, // tighten from the default of 256
21///     ..ParseOptions::default()
22/// };
23/// let doc = parse_str("<root/>", &opts).unwrap();
24/// ```
25///
26/// # `Copy` removal
27///
28/// `ParseOptions` was `Copy` before adding `external_resolver`,
29/// which holds an `Arc<dyn EntityResolver>` (not `Copy`).  The
30/// derive is now `Clone` only.  In practice almost no caller
31/// needs `Copy` for an options struct — construct once, clone
32/// when you need a second instance.
33#[derive(Debug, Clone)]
34pub struct ParseOptions {
35    /// Maximum total bytes produced across all entity expansions in a single
36    /// document.  Prevents "billion laughs" (CVE-2003-1564) and similar
37    /// amplification attacks.  Default: 1,000,000 (1 MB).
38    pub max_entity_expansion_bytes: u64,
39    /// Maximum element nesting depth.  Documents deeper than this limit are
40    /// rejected with a fatal error.  Default: 256.
41    pub max_element_depth: u32,
42    /// External resource resolver.  `None` (default) = the parser
43    /// refuses to load any externally-referenced DTD or entity —
44    /// this is the XXE-safe stance and what every untrusted-input
45    /// pipeline should keep.
46    ///
47    /// To opt into external loading, set this to one of:
48    ///
49    /// - [`FilesystemResolver`](crate::entity_resolver::FilesystemResolver) —
50    ///   loads from a configured allowlist of local directories,
51    ///   optionally consulting an OASIS catalog first.
52    /// - `NetworkResolver` (behind the `network-resolver` feature) —
53    ///   fetches over HTTPS from a configured host allowlist with
54    ///   SSRF defenses.
55    /// - [`ChainedResolver`](crate::entity_resolver::ChainedResolver) —
56    ///   composes multiple resolvers; tries each in order.
57    /// - A custom impl of the [`EntityResolver`] trait for bespoke
58    ///   setups (in-memory bundles, S3, audit-logging, …).
59    ///
60    /// Replaces the `allow_external_entities` and `allow_external_dtd`
61    /// boolean flags from earlier versions; the presence of a
62    /// resolver IS the opt-in.
63    pub external_resolver: Option<Arc<dyn EntityResolver>>,
64    /// Use XML 1.0 4th-edition (pre-2008) character class tables — BaseChar,
65    /// CombiningChar, Digit, Extender — instead of the simplified 5th-edition
66    /// Unicode-category rules.  Equivalent to libxml2's `XML_PARSE_OLD10`.
67    /// Default: `false` (5th edition, same as libxml2's default).
68    pub xml10_fourth_edition: bool,
69    /// Enforce XML Namespaces 1.0 constraints during parsing.
70    ///
71    /// When `true`, the parser rejects colons in PI targets, entity names, and
72    /// notation names (NCName requirement).  Element and attribute QName checks
73    /// are applied by [`resolve_namespaces`][crate::resolve_namespaces].
74    /// Default: `false`.
75    pub namespace_aware: bool,
76    /// Enable DTD-based validation.
77    ///
78    /// When `true`, the parser collects element and attribute declarations from
79    /// the internal DTD subset and verifies the document against them.  This
80    /// has a small performance cost because declaration names must be interned
81    /// rather than skipped.  Default: `false`.
82    pub validating: bool,
83    /// Skip the XML Name production validation on element / attribute / PI
84    /// names.  Names are still scanned (we need to know where they end) but
85    /// non-ASCII bytes are accepted without checking the Unicode-range rules
86    /// that the XML 1.0 spec specifies.
87    ///
88    /// Trade-off: a small speed-up on non-ASCII-heavy XML, at the cost of
89    /// silently accepting malformed names like `<3foo>` or names containing
90    /// disallowed Unicode characters.  Use only with trusted input.
91    ///
92    /// Default: `false` (validate per the spec).
93    pub skip_name_validation: bool,
94    /// Skip end-tag-matches-start-tag verification.  Mirrors quick-xml's
95    /// `check_end_names: false`.  With this flag set the parser does not
96    /// maintain an element stack, does not check that `</bar>` closes the
97    /// matching `<bar>`, and does not enforce the max-depth limit.
98    ///
99    /// Trade-off: noticeable speed-up on element-heavy XML (no per-element
100    /// Vec push / pop / name compare), at the cost of accepting malformed
101    /// documents like `<a><b></a></b>` silently.  Use only with trusted
102    /// input — specifically, when an earlier pass has already verified
103    /// structural well-formedness.
104    ///
105    /// Default: `false` (verify each end tag).
106    pub skip_end_tag_check: bool,
107    /// Skip the XML 1.0 § 2.2 Char-production validation that runs once over
108    /// the whole input before parsing begins.  When `false` (the default),
109    /// documents containing illegal control characters (NUL, form-feed, etc.)
110    /// or U+FFFE / U+FFFF are rejected with a fatal error.
111    ///
112    /// Trade-off: ~5–10% speed-up by skipping one O(n) scan over the input,
113    /// at the cost of accepting documents that violate XML 1.0 § 2.2.  Use
114    /// only with trusted input.
115    ///
116    /// Default: `false` (validate per the spec).
117    pub skip_xml_char_validation: bool,
118    /// Internal flag used by the streaming reader wrapper
119    /// ([`crate::streaming_reader::XmlByteStreamReader`]).  When `true`,
120    /// the reader stores element-stack names as owned `String`s
121    /// instead of byte ranges into the source buffer.  This is
122    /// required when the source buffer is rolling (compacted /
123    /// reallocated between events) because byte ranges captured at
124    /// start-tag time would point at stale bytes by end-tag time.
125    ///
126    /// Costs a small allocation per start tag — only set this when
127    /// running under the streaming wrapper.  Slurped callers should
128    /// leave it `false` (the default) for zero-copy name storage.
129    pub stream_owned_names: bool,
130    /// Skip entity expansion in [`Event::Text`][crate::reader::Event::Text]
131    /// payloads.  When `false` (the default), text events contain fully
132    /// decoded content — `&amp;` is expanded to `&`, user-declared entities
133    /// have their replacement text included, etc.  When `true`, text
134    /// events carry the raw source slice with entity references *left in
135    /// place* (`&amp;` stays `&amp;`), and the caller is responsible for
136    /// decoding them later — typically via
137    /// [`unescape`][crate::reader::unescape].
138    ///
139    /// Trade-off: dramatic speed-up on entity-heavy text (HTML / wiki
140    /// exports, RSS, Atom — anywhere `&amp;` appears repeatedly inside
141    /// `<text>` bodies) because the text-content fast path becomes a
142    /// single `memchr(b'<', …)` over the entire body instead of a
143    /// `memchr3(b'<', b'&', b']', …)` that stops at each entity.  Cost: the
144    /// caller must call `unescape` (or do the equivalent themselves)
145    /// before treating the text as decoded content.
146    ///
147    /// Only applies to SAX text events; DOM text nodes are always decoded.
148    /// Attribute values are also always decoded — attributes are typically
149    /// inspected eagerly so the gain wouldn't outweigh the API split.
150    ///
151    /// Default: `false` (expand per the spec).
152    pub skip_entity_expansion: bool,
153
154    /// Replace user-defined entity references (`&foo;` declared in
155    /// the DTD) with their replacement text during parsing.  When
156    /// `true` (the default, spec behaviour) `&foo;` is expanded
157    /// inline into a `Text` node.  When `false`, a dedicated
158    /// `NodeKind::EntityRef` node is emitted instead — the tree
159    /// preserves the original `&foo;` source form, the serializer
160    /// rewrites it verbatim, and consumers can walk the tree to
161    /// see which references appear where.
162    ///
163    /// Mirrors `lxml.etree.XMLParser(resolve_entities=False)`.
164    /// Predefined entities (`&amp;`/`&lt;`/`&gt;`/`&quot;`/`&apos;`)
165    /// and numeric character references (`&#65;`/`&#x41;`) are
166    /// ALWAYS expanded — those are part of the character data
167    /// production, not the entity-reference machinery.
168    ///
169    /// Default: `true` (resolve / expand).
170    pub resolve_entities: bool,
171
172    /// If `true`, suppress `Text` events between elements when their
173    /// content is *only* ASCII whitespace (spaces, tabs, CR, LF).
174    ///
175    /// Useful for **data-XML** workloads (SOAP envelopes, RSS / Atom,
176    /// Maven POMs, configuration files, anything where indentation is
177    /// purely formatting).  Lets the consumer skip the per-event work
178    /// of dispatching on text-event variants only to discard the
179    /// payload anyway.  Mirrors `quick-xml`'s `Reader::trim_text` and
180    /// `libxml2`'s `XML_PARSE_NOBLANKS`.
181    ///
182    /// **Don't enable this for document-style XML** — XHTML, DocBook,
183    /// any mixed-content format where the spaces between sibling
184    /// elements are semantically significant.  In `<p>foo <b>bar</b></p>`,
185    /// the space between "foo" and `<b>` is content; skipping it would
186    /// silently corrupt rendering.
187    ///
188    /// Only the *leading* whitespace inside an element is suppressed —
189    /// trailing whitespace inside non-whitespace text events (e.g.,
190    /// `<p>foo  </p>` → `Text("foo  ")`) is preserved unchanged.  This
191    /// matches the XML data model: the `Text` event is the same
192    /// content the application would see; we just don't emit a
193    /// separate event for the inter-element indent.
194    ///
195    /// Default: `false` (preserve every text event, correct per spec).
196    pub skip_inter_element_whitespace: bool,
197
198    /// Skip the eager attribute-syntax validation pass that runs on
199    /// every start tag.  When `false` (the default), the parser
200    /// catches all of:
201    ///   - bare `<` in attribute values         (§ 3.1 WFC)
202    ///   - bare `&` not part of a reference     (§ 4.1)
203    ///   - unquoted values                      (§ 3.1 [41])
204    ///   - invalid attribute name-start chars   (§ 2.3 [4])
205    ///   - missing whitespace between attrs     (§ 3.1 [40])
206    ///   - duplicate attribute names            (§ 3.1 WFC)
207    ///   - undefined / cyclic / external entity references in
208    ///     attribute values                     (§ 4.1 / § 4.4.4)
209    /// at parse time, regardless of whether the caller iterates
210    /// `BytesAttrs`.  This is what makes sup-xml's compliance
211    /// score on the W3C XML Conformance Test Suite (244/257) higher
212    /// than libxml2's (237/257).
213    ///
214    /// Trade-off: validation walks each attribute, so attribute-
215    /// heavy fixtures (OSM, RSS feeds, anything with many small
216    /// attrs per element) run ~30–50 % slower than they would with
217    /// no validation.
218    ///
219    /// When `true`, the eager pass is skipped — attribute validation
220    /// is deferred to `BytesAttrs::next()` and only fires for tags
221    /// the caller actually iterates.  Mirrors `quick-xml`'s default
222    /// behaviour (their `Attributes` iterator's `with_checks: true`
223    /// is also lazy and only catches a subset).  Use only with
224    /// trusted input — silently lets through every WFC the eager
225    /// path catches when the caller doesn't read attributes.
226    ///
227    /// Default: `false` (validate per the spec).
228    pub skip_attr_validation: bool,
229
230    /// Match libxml2's accept/reject behaviour on edge-case documents
231    /// even when our parser would otherwise be stricter than the spec
232    /// requires libxml2 to be.  Intended for migrations from
233    /// libxml2-using code where existing documents may rely on
234    /// libxml2's specific implementation quirks.
235    ///
236    /// **What this enables:**
237    ///
238    /// - **External entity references treated as opaque.**  Our default
239    ///   rejects `&extName;` when `extName` was declared `SYSTEM` /
240    ///   `PUBLIC` and we never loaded the replacement text — the spec
241    ///   calls this "undefined entity."  libxml2 silently expands to
242    ///   nothing when it can't read the external file (a class of
243    ///   silent-failure bug), and existing code may rely on that.
244    ///
245    /// **What this does NOT enable:**
246    ///
247    /// - External entity / DTD loading.  XXE protection stays on
248    ///   regardless — set [`external_resolver`](Self::external_resolver)
249    ///   to opt into external loading.
250    /// - Anything else that's a security regression.  Compat mode
251    ///   relaxes correctness checks; it does not weaken security.
252    ///
253    /// Default: `false` (strict — recommended).
254    pub libxml2_compat: bool,
255
256    /// Continue parsing past non-fatal well-formedness errors,
257    /// accumulating them on the reader instead of returning the
258    /// first one as a `Result::Err`.  Mirrors libxml2's
259    /// `XML_PARSE_RECOVER` flag.
260    ///
261    /// **Two-tier error model:**
262    ///
263    /// - **`ErrorLevel::Fatal`** — the input is unrecoverable
264    ///   (truncated mid-construct, invalid UTF-8, entity-expansion
265    ///   budget exceeded, depth-limit exceeded).  Recovery cannot
266    ///   help; `next()` returns `Err` even in recover mode.
267    /// - **`ErrorLevel::Error`** — non-fatal well-formedness
268    ///   violations (mismatched end tag, unclosed at EOF, bare `&`
269    ///   in text, undefined entity, duplicate attribute names,
270    ///   etc.).  In recover mode the parser logs the error to
271    ///   [`XmlBytesReader::recovered_errors`] and applies a
272    ///   heuristic repair so it can continue.  In strict mode
273    ///   (default) these are returned as `Err`.
274    /// - **`ErrorLevel::Warning`** — informational (rare).  Always
275    ///   logged, never stops parsing.
276    ///
277    /// **Use cases:**
278    ///
279    /// - Web crawlers / RSS readers / feed aggregators handling
280    ///   third-party XML where one bad publisher shouldn't break
281    ///   the whole pipeline.
282    /// - Diagnostic tools that want to show "here's the partial
283    ///   tree we built, here are the problems we found."
284    /// - Migration tools converting legacy malformed data into
285    ///   something cleaner.
286    ///
287    /// **Not for adversarial input.**  The existing security limits
288    /// (entity-expansion budget, depth limit) still apply — they
289    /// are `ErrorLevel::Fatal` and aren't recovered from.  But
290    /// recovery itself is a security-sensitive surface; treat the
291    /// flag as "trusted-source-but-buggy" semantics, not "accept
292    /// arbitrary input."
293    ///
294    /// Default: `false` (fail on the first non-trivial error).
295    pub recovery_mode: bool,
296
297    /// Auto-detect the input's character encoding and transcode to UTF-8
298    /// before parsing.
299    ///
300    /// **On by default** — matches libxml2's behaviour and the XML 1.0
301    /// spec's requirement (§ 4.3.3) that processors accept both UTF-8 and
302    /// UTF-16.  With this on, [`parse_bytes`](crate::parse_bytes) accepts
303    /// any encoding the [`encoding`](crate::encoding) module can detect:
304    /// UTF-8, US-ASCII, ISO-8859-1, Windows-1252, UTF-16 LE/BE, UTF-32
305    /// LE/BE, IBM037 EBCDIC, and (with the default `full-encodings`
306    /// feature) the full WHATWG set — Shift_JIS, GBK, Big5, ISO-8859-2…16,
307    /// KOI8-R, Windows-1250…1258, etc.
308    ///
309    /// Detection follows XML 1.0 Appendix F — BOM first, then the
310    /// four-byte autodetect signatures for UTF-32 / UTF-16 / EBCDIC, then
311    /// the `<?xml encoding="..."?>` declaration.
312    ///
313    /// For UTF-8 input this is **zero-copy** — the transcoder returns a
314    /// borrow of the original bytes, so the only cost is a ~100-byte
315    /// detection scan.  Non-UTF-8 input pays one allocation for the
316    /// decoded buffer.
317    ///
318    /// Set to `false` to require the input to already be UTF-8.  Use
319    /// that mode when your inputs are guaranteed-UTF-8 (you control the
320    /// producer), when you want to reject non-UTF-8 input as part of a
321    /// security posture, or when you want to skip the detection scan.
322    ///
323    /// Default: `true`.
324    pub auto_transcode: bool,
325
326    /// Force a specific input encoding, overriding auto-detection.
327    /// When `Some`, the parser transcodes the input *as* this encoding
328    /// regardless of any BOM or `<?xml encoding="…"?>` declaration —
329    /// the behaviour of libxml2's explicit `encoding` argument to
330    /// `xmlReadMemory` / `xmlCtxtReadMemory` and of `xmlSwitchEncoding`.
331    /// `None` (the default) auto-detects per [`auto_transcode`](Self::auto_transcode).
332    pub forced_encoding: Option<Encoding>,
333
334    /// Load and parse the external DTD subset when a `<!DOCTYPE r
335    /// SYSTEM "path.dtd">` (or `PUBLIC ... "path.dtd"`) declaration
336    /// is present.  The external subset's `<!ELEMENT>` and
337    /// `<!ATTLIST>` declarations are merged into
338    /// [`XmlBytesReader::dtd`](crate::xml_bytes_reader::XmlBytesReader::dtd),
339    /// alongside any internal-subset declarations.
340    ///
341    /// libxml2 calls the same feature `XML_PARSE_DTDLOAD`.  Off by
342    /// default because loading arbitrary local files referenced
343    /// inside a document is a security-sensitive surface (classic
344    /// XXE vector) — turn it on only when you trust the input.
345    ///
346    /// **Scope**: when on, this enables loading for *both* the
347    /// external DTD subset AND external general entities declared
348    /// like `<!ENTITY x SYSTEM "file.txt">`.  The latter is the
349    /// hotter XXE channel — references such as `&x;` substitute
350    /// the file's contents into the parsed tree, letting a
351    /// malicious document exfiltrate any file the parser process
352    /// can read.  Both load behaviours share this flag because
353    /// libxml2 treats them as a single switch and the entity
354    /// declarations live inside the DTD anyway.  Need finer
355    /// control?  Wire an [`external_resolver`](Self::external_resolver)
356    /// — it's consulted first and can whitelist or deny per
357    /// request.
358    ///
359    /// Resolution: the SYSTEM literal is treated as a filesystem
360    /// path; relative paths resolve against
361    /// [`base_url`](Self::base_url) when set, otherwise against the
362    /// process's current working directory.  Network URIs
363    /// (`http://...`) are NOT fetched — they're silently treated
364    /// as a missing file and ignored.
365    ///
366    /// Default: `false`.
367    pub load_external_dtd: bool,
368
369    /// Whether a declared external *general* entity may be loaded (via
370    /// [`external_resolver`](Self::external_resolver)) and inlined when it
371    /// is referenced in content.  When `false`, the resolver is still used
372    /// for the external DTD subset and parameter entities, but a reference
373    /// to an external general entity is treated as undefined — the entity's
374    /// content is never fetched.
375    ///
376    /// This is the second gate libxml2 applies: external general-entity
377    /// expansion requires both a loader *and* the caller having opted in
378    /// (libxml2's `XML_PARSE_NOENT` together with a non-restricting
379    /// `getEntity`). lxml's default (`resolve_entities='internal'`) sets
380    /// this `false` to avoid inlining attacker-controlled external content
381    /// (XXE/SSRF).  Only consulted when an `external_resolver` is set.
382    ///
383    /// Default: `true` (the resolver's presence is the opt-in; callers that
384    /// want the stricter stance set this `false`).
385    pub resolve_external_entities: bool,
386
387    /// Base path used to resolve relative SYSTEM literals during
388    /// external-DTD and external-entity loading.  When `Some`, a
389    /// relative SYSTEM literal is joined against this path's parent
390    /// directory rather than the process's current working
391    /// directory.  Has no effect when
392    /// [`load_external_dtd`](Self::load_external_dtd) is `false`.
393    ///
394    /// Typically populated by the parse entry point with the
395    /// document's source URL (e.g. `xmlReadFile`'s `filename`
396    /// argument), so a file at `/data/doc.xml` referencing
397    /// `<!DOCTYPE r SYSTEM "schema.dtd">` finds `/data/schema.dtd`.
398    pub base_url: Option<String>,
399
400    /// Drop comment nodes during the parse instead of building them
401    /// into the tree.  Mirrors libxml2's effect when a consumer NULLs
402    /// the SAX `comment` callback (lxml's `XMLParser(remove_comments=
403    /// True)`).  Default: `false`.
404    pub remove_comments: bool,
405
406    /// Drop processing-instruction nodes during the parse.  Mirrors
407    /// libxml2's effect when a consumer NULLs the SAX
408    /// `processingInstruction` callback (lxml's
409    /// `XMLParser(remove_pis=True)`).  Default: `false`.
410    pub remove_pis: bool,
411
412    /// Deliver CDATA-section content as ordinary text nodes instead of
413    /// dedicated CDATA nodes (libxml2's `XML_PARSE_NOCDATA`; lxml's
414    /// `XMLParser(strip_cdata=True)`, which is its default).  On
415    /// serialization the content round-trips as escaped text rather
416    /// than `<![CDATA[…]]>`.  Default: `false` (CDATA preserved).
417    pub cdata_as_text: bool,
418}
419
420impl Default for ParseOptions {
421    fn default() -> Self {
422        Self {
423            max_entity_expansion_bytes: 1_000_000,
424            max_element_depth: 256,
425            external_resolver: None,
426            xml10_fourth_edition: false,
427            namespace_aware: false,
428            validating: false,
429            skip_name_validation: false,
430            skip_end_tag_check: false,
431            skip_xml_char_validation: false,
432            stream_owned_names: false,
433            skip_entity_expansion: false,
434            resolve_entities: true,
435            skip_inter_element_whitespace: false,
436            skip_attr_validation: false,
437            libxml2_compat: false,
438            recovery_mode: false,
439            auto_transcode: true,
440            forced_encoding: None,
441            load_external_dtd: false,
442            resolve_external_entities: true,
443            base_url: None,
444            remove_comments: false,
445            remove_pis: false,
446            cdata_as_text: false,
447        }
448    }
449}