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 — `&` 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* (`&` stays `&`), 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 `&` 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 (`&`/`<`/`>`/`"`/`'`)
165 /// and numeric character references (`A`/`A`) 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}