sup_xml_core/xml_bytes_reader.rs
1//! `XmlBytesReader` — bytes-typed streaming SAX-style API.
2//!
3//! The byte-level parsing engine. Events carry `Cow<'src, [u8]>` payloads
4//! sliced directly from the source — no UTF-8 cast at the emission
5//! boundary. [`XmlReader`](crate::reader::XmlReader) is a thin wrapper
6//! over this type that adds the `&[u8] → &str` conversion.
7//!
8//! ## Why two readers exist
9//!
10//! Some callers prefer raw bytes — byte-literal tag matching
11//! (`name == b"item"`), hash/digest pipelines, format conversion, byte
12//! forwarding — and would rather not pay for the type system's `&str`
13//! guarantee. `XmlBytesReader` (this module) serves them. Most callers
14//! want validated strings; [`XmlReader`](crate::reader::XmlReader) serves
15//! them and is the recommended default. The two share a single parser;
16//! the only difference is the payload type each emits.
17//!
18//! ## UTF-8 invariant
19//!
20//! Bytes emitted here are still valid UTF-8 — the [`Scanner`] enforces
21//! that at construction time (`from_bytes` validates; `from_bytes_unchecked`
22//! documents the caller obligation). The bytes-typed API simply chooses
23//! not to surface that fact through the type system, which is exactly
24//! what lets the str-typed wrapper convert via `from_utf8_unchecked` for
25//! free.
26//!
27//! ## When to choose which
28//!
29//! Reach for `XmlBytesReader` when you want to compare against byte
30//! literals, forward bytes to another sink, or feed a hash/digest
31//! pipeline. Reach for [`XmlReader`](crate::reader::XmlReader) when you
32//! want `&str` payloads — string formatting, regex, anything that wants
33//! Unicode-typed input. Performance is identical; the choice is purely
34//! about the payload type you'd rather work with.
35
36use std::borrow::Cow;
37use rustc_hash::{FxHashMap as HashMap, FxHashSet};
38
39use memchr::{memchr, memchr3};
40
41use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
42use crate::options::ParseOptions;
43use crate::scanner::{Scanner, is_pubid_char, is_xml_char, is_xml_11_char, validate_xml_chars};
44
45// ── public types ──────────────────────────────────────────────────────────────
46
47/// A single attribute from a start tag, with a zero-copy value when possible.
48///
49/// The `name` is always borrowed directly from the source — XML name
50/// chars can't include entity refs, so no allocation is ever needed.
51/// The `value` is `Cow::Borrowed` for the common case (no entity
52/// references in the literal) and `Cow::Owned` only when the value
53/// contained `&entity;` references that the parser had to expand.
54#[derive(Debug)]
55pub struct BytesAttr<'src> {
56 /// Source-borrowed attribute name (e.g. `b"id"`, `b"xmlns:foo"`).
57 pub name: &'src [u8],
58 /// Attribute value. Borrowed from source when no entity expansion
59 /// happened; owned otherwise.
60 pub value: Cow<'src, [u8]>,
61}
62
63impl<'src> BytesAttr<'src> {
64 /// Attribute name as a borrowed source slice. Same as `self.name`.
65 pub fn name(&self) -> &'src [u8] { self.name }
66
67 /// Attribute value, borrowing from the source when no entity
68 /// references appeared in the literal. Same as `&self.value`.
69 pub fn value(&self) -> &[u8] { &self.value }
70}
71
72/// Lazy iterator over the attributes of a start tag, yielding raw byte
73/// payloads — see [`BytesAttr`] for the shape of each item.
74///
75/// `BytesAttrs` is returned inside [`BytesEvent::StartElement`]. Each
76/// `.next()` call parses one `name="value"` pair on demand from a slice
77/// of the source. Drop it without iterating and you pay roughly the
78/// cost of recording two byte offsets — a deliberate trade-off, since
79/// many streaming consumers care only about element names and skip
80/// attribute parsing entirely.
81///
82/// # When to use this vs [`XmlBytesReader::next_into`]
83///
84/// * Use the lazy iterator (this) for selective access — say, "give me
85/// the `id=` attribute but ignore the rest" — or when you don't read
86/// attributes at all on most elements.
87/// * Use [`XmlBytesReader::next_into`] for eager access into a
88/// reusable buffer when you'll consume *every* attribute on every
89/// element anyway; it amortises the iterator setup across calls.
90///
91/// # Internals
92///
93/// The iterator owns a sub-`Scanner` over the byte range between the
94/// element name and the closing `>` (or `/>`) of the start tag. That
95/// sub-scanner means the parent `XmlBytesReader` can move on to the
96/// next event independently of whether the caller iterates the attrs.
97/// Internal entity-expansion state (`entities`, `expansion_bytes`) is
98/// borrowed mutably from the parent so attribute values containing
99/// `&entity;` references expand against the same expansion-byte budget
100/// the rest of the document uses.
101///
102/// # Error semantics
103///
104/// If an attribute fails to parse, the iterator yields `Some(Err(_))`
105/// once and returns `None` on every subsequent call. A malformed
106/// attribute terminates iteration; the caller should bail. Names and
107/// values both validate against [`ParseOptions`](crate::ParseOptions)
108/// (the same options that govern the parent reader).
109pub struct BytesAttrs<'r, 'src> {
110 scan: Scanner<'src, 'r>,
111 entities: &'r HashMap<String, EntityDecl>,
112 expansion_bytes: &'r mut u64,
113 done: bool,
114 /// Mirror of [`XmlBytesReader::standalone_yes`] for the standalone
115 /// WFC check on entity references inside attribute values.
116 standalone_yes: bool,
117 /// Mirror of [`XmlBytesReader::is_xml_11`] for the text-decl
118 /// version check in any external entities referenced from
119 /// inside an attribute value's expansion.
120 is_xml_11: bool,
121}
122
123/// Replacement-text + kind for a single DTD-declared entity.
124///
125/// XML 1.0 §4.1 distinguishes *internal* entities (whose body is
126/// an inline literal at declaration time) from *external* entities
127/// (whose body comes from a referenced SYSTEM / PUBLIC resource).
128/// The distinction matters in several places:
129///
130/// * §4.3.1 — only *external* parsed entities may begin with a
131/// text declaration (`<?xml … ?>`). An identical-looking PI in
132/// an internal entity's content is not-wf.
133/// * §3.3.3 / §4.4.4 — *external* general entity references are
134/// forbidden in attribute values.
135/// * §4.4.2 — an *external* parameter entity's replacement text
136/// is processed as if external markup.
137/// * libxml2-compat mode silently expands references to declared
138/// but unloaded external entities to empty rather than erroring.
139///
140/// Tracking the kind on the entity value itself lets every consumer
141/// branch on the right rule without consulting a side-table — and
142/// the type system guarantees we handle each variant.
143#[derive(Debug, Clone)]
144pub(crate) enum EntityKind {
145 /// `<!ENTITY name "literal text">`. The string IS the
146 /// replacement text, taken verbatim from the EntityValue.
147 InternalText(String),
148 /// `<!ENTITY name SYSTEM "uri">` (or PUBLIC) whose bytes were
149 /// successfully loaded by the resolver / `load_external_dtd`
150 /// path. The string is the transcoded UTF-8 replacement text.
151 ExternalLoaded(String),
152 /// `<!ENTITY name SYSTEM "uri">` declared but not loaded
153 /// (typically: no resolver was configured, or the resolver
154 /// refused). References in `libxml2_compat` mode expand to
155 /// empty; references in strict mode raise the usual
156 /// "undefined entity" error.
157 ExternalUnloaded,
158}
159
160impl EntityKind {
161 /// The replacement text for this entity, if we have one.
162 /// `ExternalUnloaded` returns `None`.
163 pub(crate) fn replacement(&self) -> Option<&str> {
164 match self {
165 EntityKind::InternalText(s) | EntityKind::ExternalLoaded(s) => Some(s.as_str()),
166 EntityKind::ExternalUnloaded => None,
167 }
168 }
169 /// `true` for `ExternalLoaded` / `ExternalUnloaded` — i.e.
170 /// when the entity's *replacement text* comes from outside
171 /// (via the resolver). Distinct from `declared_external`
172 /// on [`EntityDecl`], which tracks where the *declaration*
173 /// itself appeared (internal subset vs. external subset).
174 pub(crate) fn is_external_value(&self) -> bool {
175 matches!(self, EntityKind::ExternalLoaded(_) | EntityKind::ExternalUnloaded)
176 }
177}
178
179/// An entity declaration: its replacement-text [`EntityKind`] plus
180/// the WFC-relevant fact of where the declaration itself appeared.
181///
182/// XML 1.0 § 4.1 "WFC: Entity Declared" (and § 2.9 the
183/// `standalone="yes"` interpretation): in a standalone document,
184/// references to entities whose declaration lived in the external
185/// subset (or in an external parameter-entity's replacement text)
186/// are not well-formed. Tracking that here lets the reference-time
187/// check apply the rule precisely.
188#[derive(Debug, Clone)]
189pub(crate) struct EntityDecl {
190 pub(crate) kind: EntityKind,
191 /// `true` when the declaration was read from the external
192 /// subset *or* from a PE-replacement text (which is "external"
193 /// in the spec's sense). Initialised at `parse_entity_decl`
194 /// time from `Scanner::on_original_source()`.
195 pub(crate) declared_external: bool,
196 /// Absolute URL the entity's bytes were loaded from, when this
197 /// is an external entity that the resolver successfully
198 /// returned bytes for. Used at reference-expansion time to
199 /// seed the new entity-stream frame's `base_uri` so nested
200 /// SYSTEM identifiers inside this entity's replacement text
201 /// can be resolved against the entity's own URL rather than
202 /// the document's (XML 1.0 § 4.2.2 + errata E18). `None` for
203 /// internal entities and for externals that failed to load.
204 pub(crate) source_uri: Option<String>,
205}
206
207impl EntityDecl {
208 fn replacement(&self) -> Option<&str> { self.kind.replacement() }
209}
210
211/// A declared-but-not-yet-loaded external *general* entity. XML 1.0
212/// § 4.4.3: a parsed external general entity is loaded only when it is
213/// referenced — declaring `<!ENTITY e SYSTEM "x">` must not, on its
214/// own, trigger any I/O (an unreferenced entity whose target is missing
215/// is not an error, and eagerly fetching it would be an XXE/SSRF
216/// vector). We stash the identifiers at declaration time and resolve
217/// them through the configured `external_resolver` on first reference.
218#[derive(Debug, Clone)]
219pub(crate) struct DeferredExternal {
220 pub(crate) system_id: String,
221 pub(crate) public_id: Option<String>,
222}
223
224impl<'src> Iterator for BytesAttrs<'_, 'src> {
225 type Item = Result<BytesAttr<'src>>;
226
227 fn next(&mut self) -> Option<Self::Item> {
228 if self.done { return None; }
229 // Skip any whitespace between the previous attr and this one.
230 self.scan.skip_ws();
231 if self.scan.is_eof() {
232 self.done = true;
233 return None;
234 }
235 match self.read_attr() {
236 Ok(a) => Some(Ok(a)),
237 Err(e) => { self.done = true; Some(Err(e)) }
238 }
239 }
240}
241
242// ── BytesAttrs internals ────────────────────────────────────────────────────
243//
244// One-attr-at-a-time parsing logic invoked from `Iterator::next` above.
245// `read_attr` handles the `name="value"` shape; `scan_att_value_cow` is
246// the value-side fast path (memchr3 to find the closing quote, `&`, or
247// `<`) with a slow path for entity expansion.
248impl<'src> BytesAttrs<'_, 'src> {
249 fn read_attr(&mut self) -> Result<BytesAttr<'src>> {
250 // Attribute names are always source-borrowed: XML disallows
251 // entity refs inside names, so the scanner's name range is
252 // guaranteed to be a clean slice into `'src`.
253 let (n_start, n_end) = self.scan.scan_name_raw()?;
254 let name = match self.scan.current_borrowed_bytes() {
255 Some(src) => &src[n_start..n_end],
256 None => return Err(self.scan.err(
257 "attribute name from inside entity-expansion stream not supported"
258 )),
259 };
260 self.scan.skip_ws();
261 self.scan.expect(b'=')?;
262 self.scan.skip_ws();
263 let value = self.scan_att_value_cow()?;
264 Ok(BytesAttr { name, value })
265 }
266
267 /// Read one attribute *value* — the part between the matching quotes
268 /// after `=`. Two paths:
269 ///
270 /// * **Fast path:** the value contains no `&` (no entity refs) — we
271 /// `memchr3` for `"`/`'`/`&`/`<` in one SIMD step, find the closing
272 /// quote, and return a `Cow::Borrowed` slice into the source.
273 /// * **Slow path:** an `&` was seen. Copy the clean prefix into an
274 /// owned buffer, then loop expanding entity refs (with the parent
275 /// reader's expansion-byte budget) until we hit the closing quote.
276 /// Returns `Cow::Owned`.
277 ///
278 /// `<` inside an attribute value is a hard error (XML 1.0 § 3.1).
279 fn scan_att_value_cow(&mut self) -> Result<Cow<'src, [u8]>> {
280 let q = match self.scan.advance() {
281 Some(b @ (b'"' | b'\'')) => b,
282 Some(b) => return Err(self.scan.err(format!("expected quote, got '{}'", b as char))),
283 None => return Err(self.scan.err("expected quote, got EOF")),
284 };
285
286 let start = self.scan.cur_pos();
287
288 // SIMD fast path: scan for closing quote, `&`, or `<` simultaneously.
289 // memchr3 always finds *something* at the first interesting byte, so
290 // this is a single match — no loop needed (each branch terminates).
291 let tail = self.scan.cur_tail();
292 match memchr3(q, b'&', b'<', tail) {
293 None => return Err(self.scan.err("unterminated attribute value")),
294 Some(off) => {
295 self.scan.cur_advance_pos(off);
296 match self.scan.cur_bytes()[self.scan.cur_pos()] {
297 b if b == q => {
298 // §3.3.3 CDATA-default normalization — rewrite
299 // `\t` / `\n` / `\r` (and in XML 1.1 also NEL
300 // and LS) to `#x20`. Stays zero-copy when the
301 // value carries only literal spaces.
302 let s = self.scan.cur_slice(start, self.scan.cur_pos());
303 let s = maybe_normalize_attr_value(s, self.is_xml_11);
304 self.scan.advance();
305 return Ok(s);
306 }
307 b'<' => return Err(self.scan.err("'<' not allowed in attribute value")),
308 b'&' => {} // fall through to slow path
309 _ => unreachable!(),
310 }
311 }
312 }
313
314 // Slow path: entity / char reference found — copy clean prefix
315 // (applying §3.3.3 normalization to those literal source
316 // bytes), then expand. Subsequent literal segments and the
317 // expansion outputs are appended separately so that character
318 // references can deliver raw `\t` / `\n` / `\r` without being
319 // rewritten away.
320 let mut buf = Vec::<u8>::new();
321 append_attr_segment(&self.scan, start, self.scan.cur_pos(), &mut buf, self.is_xml_11);
322 let budget = self.scan.opts.max_entity_expansion_bytes;
323
324 // Attribute values cannot contain `<`, so no element can open inside
325 // one — the depth check is trivially satisfied at depth 0.
326 let depth: u32 = 0;
327
328 loop {
329 let tail = self.scan.cur_tail();
330 match memchr3(q, b'&', b'<', tail) {
331 None => {
332 // End of current stream. In an entity-replacement stream,
333 // pop and continue — the value's closing quote must come
334 // from the original stream (XML 1.0 § 4.4.5 — quotes in
335 // replacement text are normal data characters).
336 let end = self.scan.cur_len();
337 let from = self.scan.cur_pos();
338 append_attr_segment(&self.scan, from, end, &mut buf, self.is_xml_11);
339 self.scan.cur_set_pos(end);
340 if self.scan.try_pop_entity_stream() {
341 continue;
342 }
343 return Err(self.scan.err("unterminated attribute value"));
344 }
345 Some(off) => {
346 let from = self.scan.cur_pos();
347 append_attr_segment(&self.scan, from, from + off, &mut buf, self.is_xml_11);
348 self.scan.cur_advance_pos(off);
349 match self.scan.cur_bytes()[self.scan.cur_pos()] {
350 b if b == q => {
351 // Closing quote only counts when we're back at
352 // the original stream — quotes inside an entity
353 // replacement are literal data per spec.
354 if self.scan.in_entity() {
355 buf.push(b);
356 self.scan.advance();
357 } else {
358 self.scan.advance();
359 break;
360 }
361 }
362 b'<' => return Err(self.scan.err("'<' not allowed in attribute value")),
363 // Pass `None` for the recovery sink: BytesAttrs
364 // doesn't carry a reference to the parent
365 // reader's recovered_errors list (would
366 // require a struct change). Recovery for
367 // attribute-value entity references would be
368 // a later add-on requiring that struct change.
369 b'&' => expand_reference_bytes(
370 &mut self.scan, &mut buf, self.entities, &mut *self.expansion_bytes, budget, depth, None,
371 // Attribute values don't get the XML 1.0
372 // errata E13 relaxation; only text-content
373 // refs do. Attr-value refs stay strict-WF.
374 false,
375 self.standalone_yes,
376 self.is_xml_11,
377 )?,
378 _ => unreachable!(),
379 }
380 }
381 }
382 }
383 // No wholesale normalization here — literal source segments
384 // were already normalized as they were appended via
385 // `append_attr_segment`, and the expansion outputs from
386 // character references must arrive verbatim.
387 Ok(Cow::Owned(buf))
388 }
389}
390
391// ── tag types ────────────────────────────────────────────────────────────────
392//
393// Each `BytesEvent` variant wraps one of the structs below. They carry
394// only what's needed to produce their public payload on demand — usually
395// a few `u32` offsets into the source buffer. Discarding an event
396// without calling any method does no extraction work; the structs are
397// trivially droppable.
398
399/// A start-tag event (`<element ...>` or `<element/>`).
400///
401/// Carries source offsets only — no name extraction or attribute
402/// parsing happens until you call a method. Drop without calling
403/// anything and you've paid nothing.
404pub struct BytesStartTag<'r, 'src> {
405 src: &'src [u8],
406 name_start: u32,
407 name_end: u32,
408 /// Cold path: start tag parsed inside an entity-replacement
409 /// stream. The `(name_start, name_end)` range would index into
410 /// the entity-stream bytes, not `src`, so we capture the name
411 /// here instead. `None` for the common source-borrowed case.
412 owned_name: Option<Box<[u8]>>,
413 /// Cold path: when the start tag was parsed inside an
414 /// entity-replacement stream, the lazy [`attrs()`] iterator has
415 /// no way to surface bytes that don't live in `src`. We pre-
416 /// parse the attribute list eagerly into owned pairs and stash
417 /// them here; consumers that need entity-stream attrs (the DOM
418 /// builder, for instance) read this first. `None` for the
419 /// common source-borrowed case.
420 entity_attrs: Option<Vec<(Vec<u8>, Vec<u8>)>>,
421 attrs_start: u32,
422 attrs_end: u32,
423 entities: &'r HashMap<String, EntityDecl>,
424 expansion_bytes: &'r mut u64,
425 /// Borrowed (not copied) — `ParseOptions` is ~32 bytes; for
426 /// elements where `attrs()` is never called we'd otherwise pay the
427 /// copy for nothing. At `attrs()` time we deref + copy into the
428 /// child Scanner.
429 opts: &'r ParseOptions,
430 /// Mirror of [`XmlBytesReader::standalone_yes`] for the standalone
431 /// WFC check on entity references inside attribute values.
432 standalone_yes: bool,
433 /// Mirror of [`XmlBytesReader::is_xml_11`] threaded through to
434 /// any text-decl parsing during attribute-entity expansion.
435 is_xml_11: bool,
436}
437
438impl<'r, 'src> BytesStartTag<'r, 'src> {
439 /// Element name as bytes. Borrowed from the source slice on the
440 /// common path; tied to `&self` on the cold path where the start
441 /// tag was read from inside an entity-replacement stream and the
442 /// name had to be captured separately. Use [`name_cow`] when you
443 /// need a lifetime that outlives `self`.
444 ///
445 /// [`name_cow`]: BytesStartTag::name_cow
446 #[inline]
447 pub fn name(&self) -> &[u8] {
448 match &self.owned_name {
449 Some(b) => b,
450 None => &self.src[self.name_start as usize..self.name_end as usize],
451 }
452 }
453
454 /// Element name with the `'src` lifetime preserved when possible.
455 /// Source-borrowed names round-trip without copying; an
456 /// entity-stream name is returned as `Cow::Owned` (heap copy).
457 pub fn name_cow(&self) -> Cow<'src, [u8]> {
458 match &self.owned_name {
459 Some(b) => Cow::Owned(b.to_vec()),
460 None => Cow::Borrowed(
461 &self.src[self.name_start as usize..self.name_end as usize]
462 ),
463 }
464 }
465
466 /// Byte offset of this element's name within the source buffer.
467 /// Useful for line-number computation at parser-side
468 /// (translate via `compute_line_col` only when the consumer
469 /// actually asks).
470 ///
471 /// For start tags read from inside an entity-replacement stream,
472 /// the source-offset is meaningless and returns `0`; check
473 /// [`name_cow`](Self::name_cow) returning `Cow::Owned` to detect
474 /// that case.
475 #[inline]
476 pub fn name_offset(&self) -> u32 {
477 self.name_start
478 }
479
480 /// Raw byte range of the attrs region (between the name and the
481 /// closing `>` / `/>`). Useful for callers that want to do their
482 /// own scanning rather than going through the iterator.
483 #[inline]
484 pub fn attrs_bytes(&self) -> &'src [u8] {
485 &self.src[self.attrs_start as usize..self.attrs_end as usize]
486 }
487
488 /// Pre-parsed attribute pairs from an entity-replacement stream,
489 /// or `None` for the common source-borrowed case. When `Some`,
490 /// callers should consume this list instead of [`attrs()`] — the
491 /// lazy iterator can't surface attrs whose bytes don't live in
492 /// `src`.
493 #[inline]
494 pub fn entity_attrs(&self) -> Option<&[(Vec<u8>, Vec<u8>)]> {
495 self.entity_attrs.as_deref()
496 }
497
498 /// Iterate the attributes. Consumes the tag — once you ask for
499 /// attrs you've committed. No-attr elements do effectively zero
500 /// work here (the iterator's first `.next()` short-circuits on EOF).
501 pub fn attrs(self) -> BytesAttrs<'r, 'src> {
502 BytesAttrs {
503 // Borrow the parent reader's options — no clone on the hot path.
504 // The inner Scanner's `'opt` lifetime is `'r`, the same lifetime
505 // the StartTag carries; both end when this BytesAttrs drops.
506 scan: Scanner::new(self.attrs_bytes(), Cow::Borrowed(self.opts)),
507 entities: self.entities,
508 expansion_bytes: self.expansion_bytes,
509 done: false,
510 standalone_yes: self.standalone_yes,
511 is_xml_11: self.is_xml_11,
512 }
513 }
514}
515
516impl std::fmt::Debug for BytesStartTag<'_, '_> {
517 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518 f.debug_struct("BytesStartTag")
519 .field("name", &String::from_utf8_lossy(self.name()))
520 .field("attrs", &String::from_utf8_lossy(self.attrs_bytes()))
521 .finish()
522 }
523}
524
525/// An end-tag event (`</element>` — or the synthetic close emitted
526/// after every self-closing `<element/>`). Holds the matched tag's
527/// name as a source-borrowed slice.
528pub struct BytesEndTag<'src> {
529 src: &'src [u8],
530 name_start: u32,
531 name_end: u32,
532}
533
534impl<'src> BytesEndTag<'src> {
535 #[inline]
536 pub fn name(&self) -> &'src [u8] {
537 &self.src[self.name_start as usize..self.name_end as usize]
538 }
539}
540
541impl std::fmt::Debug for BytesEndTag<'_> {
542 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
543 f.debug_struct("BytesEndTag")
544 .field("name", &String::from_utf8_lossy(self.name()))
545 .finish()
546 }
547}
548
549/// Character-data text between elements. `Cow::Borrowed` for the
550/// common case (no entity refs in the literal); `Cow::Owned` only when
551/// the parser had to expand `&entity;` references.
552pub struct BytesText<'src> { inner: Cow<'src, [u8]> }
553impl<'src> BytesText<'src> {
554 #[inline] pub fn as_bytes(&self) -> &[u8] { &self.inner }
555 #[inline] pub fn into_bytes(self) -> Cow<'src, [u8]> { self.inner }
556}
557impl std::fmt::Debug for BytesText<'_> {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 write!(f, "BytesText({:?})", String::from_utf8_lossy(&self.inner))
560 }
561}
562
563/// A `<![CDATA[…]]>` section. Always source-borrowed in practice
564/// (entity references inside CDATA aren't expanded per spec).
565pub struct BytesCData<'src> { inner: Cow<'src, [u8]> }
566impl<'src> BytesCData<'src> {
567 #[inline] pub fn as_bytes(&self) -> &[u8] { &self.inner }
568 #[inline] pub fn into_bytes(self) -> Cow<'src, [u8]> { self.inner }
569}
570impl std::fmt::Debug for BytesCData<'_> {
571 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572 write!(f, "BytesCData({:?})", String::from_utf8_lossy(&self.inner))
573 }
574}
575
576/// An XML comment (`<!-- ... -->`). The payload is the text strictly
577/// between the delimiters.
578pub struct BytesComment<'src> { inner: Cow<'src, [u8]> }
579impl<'src> BytesComment<'src> {
580 #[inline] pub fn as_bytes(&self) -> &[u8] { &self.inner }
581 #[inline] pub fn into_bytes(self) -> Cow<'src, [u8]> { self.inner }
582}
583impl std::fmt::Debug for BytesComment<'_> {
584 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585 write!(f, "BytesComment({:?})", String::from_utf8_lossy(&self.inner))
586 }
587}
588
589/// A processing instruction (`<?target content?>`).
590pub struct BytesPi<'src> {
591 target_: Cow<'src, [u8]>,
592 content_: Cow<'src, [u8]>,
593}
594impl<'src> BytesPi<'src> {
595 #[inline] pub fn target(&self) -> &[u8] { &self.target_ }
596 #[inline] pub fn content(&self) -> &[u8] { &self.content_ }
597 #[inline] pub fn into_parts(self) -> (Cow<'src, [u8]>, Cow<'src, [u8]>) {
598 (self.target_, self.content_)
599 }
600}
601impl std::fmt::Debug for BytesPi<'_> {
602 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603 f.debug_struct("BytesPi")
604 .field("target", &String::from_utf8_lossy(&self.target_))
605 .field("content", &String::from_utf8_lossy(&self.content_))
606 .finish()
607 }
608}
609
610/// A streaming XML event with **lazy** access to its payload.
611///
612/// Returned by [`XmlBytesReader::next`]. Each variant wraps a tag
613/// struct whose methods (`name()`, `attrs()`, etc.) extract data on
614/// demand — discard the event without calling a method and you pay
615/// almost nothing. For an eager API that fills a caller-owned buffer
616/// see [`XmlBytesReader::next_into`] which returns [`BytesEventInto`].
617#[derive(Debug)]
618pub enum BytesEvent<'r, 'src> {
619 /// An opening (or empty-element) start tag.
620 StartElement(BytesStartTag<'r, 'src>),
621 /// A closing tag. Emitted once for each `StartElement`, including
622 /// for empty elements (`<br/>` emits `StartElement` then `EndElement`).
623 EndElement(BytesEndTag<'src>),
624 /// Character data between tags.
625 Text(BytesText<'src>),
626 /// A `<![CDATA[…]]>` section.
627 CData(BytesCData<'src>),
628 /// An XML comment.
629 Comment(BytesComment<'src>),
630 /// A processing instruction.
631 Pi(BytesPi<'src>),
632 /// An unresolved entity reference — `&foo;` left literal in the
633 /// event stream. Emitted only when
634 /// [`ParseOptions::resolve_entities`] is `false` and the
635 /// reference is a *user-declared* entity (predefined `&`
636 /// etc. and numeric `&#NN;` refs are always expanded into
637 /// `Text` payloads). Carries the entity name (without the
638 /// leading `&` / trailing `;`).
639 EntityRef(BytesEntityRef<'src>),
640 /// The document has been fully consumed.
641 Eof,
642}
643
644/// `BytesEvent::EntityRef` payload — the entity name only. The
645/// literal source form `&{name};` is reconstructable by callers.
646pub struct BytesEntityRef<'src> {
647 src: &'src [u8],
648 name_start: u32,
649 name_end: u32,
650}
651
652impl<'src> BytesEntityRef<'src> {
653 /// Entity name as a borrowed source slice (e.g. `b"foo"` for
654 /// the reference `&foo;`).
655 #[inline]
656 pub fn name(&self) -> &'src [u8] {
657 &self.src[self.name_start as usize..self.name_end as usize]
658 }
659}
660
661impl std::fmt::Debug for BytesEntityRef<'_> {
662 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663 f.debug_struct("BytesEntityRef")
664 .field("name", &String::from_utf8_lossy(self.name()))
665 .finish()
666 }
667}
668
669/// A streaming XML event with **attributes already parsed into a caller-owned
670/// buffer**.
671///
672/// Returned by [`XmlBytesReader::next_into`]. The buffer the caller passes is
673/// cleared on each call and filled with the start tag's attributes, allowing
674/// buffer reuse across many events. For lazy attribute access without a
675/// buffer see [`XmlBytesReader::next`] which returns [`BytesEvent`].
676#[derive(Debug)]
677pub enum BytesEventInto<'src> {
678 /// An opening (or empty-element) start tag. Attributes are in the
679 /// caller-owned buffer passed to [`XmlBytesReader::next_into`].
680 StartElement { name: Cow<'src, [u8]> },
681 /// A closing tag. Emitted once for each `StartElement`, including for
682 /// empty elements.
683 EndElement { name: Cow<'src, [u8]> },
684 Text(Cow<'src, [u8]>),
685 CData(Cow<'src, [u8]>),
686 Comment(Cow<'src, [u8]>),
687 Pi { target: Cow<'src, [u8]>, content: Cow<'src, [u8]> },
688 /// An unresolved entity reference — emitted only when
689 /// [`ParseOptions::resolve_entities`] is `false`.
690 EntityRef { name: Cow<'src, [u8]> },
691 Eof,
692}
693
694// ── reader ────────────────────────────────────────────────────────────────────
695
696/// Inter-call state of the reader's `next()` dispatcher.
697///
698/// Three states folded into one byte so the steady-state path is a
699/// single `match` discriminant test instead of two booleans (`Option`
700/// niche check + `prolog_done`). The vast majority of `next()` calls
701/// land in `Steady`; the other arms self-transition back to `Steady`
702/// after handling their one-shot work.
703enum NextState {
704 /// Prolog has not been parsed yet. Set on construction; flipped
705 /// to `Steady` after the first `next()` call parses the prolog.
706 NeedsProlog,
707 /// Steady-state — the common case. Read the next event off the
708 /// scanner directly; no extra bookkeeping.
709 Steady,
710 /// An empty element (`<foo/>`) was just emitted as `StartElement`;
711 /// the next `next()` call must synthesise the matching `EndElement`
712 /// from the stored name range. Transitions back to `Steady` once
713 /// the synthetic close fires.
714 PendingEnd(u32, u32),
715 /// `resolve_entities=false` saw a user-defined `&foo;` inside
716 /// text content. The current call emits accumulated `Text`
717 /// (possibly empty); this state carries the entity-name source
718 /// offsets so the next call can emit the `EntityRef` event
719 /// without re-scanning.
720 PendingEntityRef(u32, u32),
721}
722
723/// Captured XML declaration fields (`<?xml version="…" encoding="…"
724/// standalone="…"?>`). Populated by [`XmlBytesReader`] during prolog
725/// parsing; available via [`XmlBytesReader::xml_decl`] after the first
726/// event has been read.
727///
728/// `encoding` is `None` when the declaration omitted it (XML 1.0 § 2.8
729/// allows that — the file is then implicitly UTF-8 or UTF-16 as detected
730/// from the BOM). `standalone` is `None` when the declaration omitted
731/// `standalone=…`.
732#[derive(Debug, Clone, PartialEq, Eq)]
733pub struct XmlDeclInfo {
734 pub version: String,
735 pub encoding: Option<String>,
736 pub standalone: Option<bool>,
737}
738
739/// One stack frame in [`XmlBytesReader::element_stack`]. See its
740/// field doc for why this exists in two flavours.
741enum ElementStackEntry {
742 /// Hot path — start tag read from the original source. The
743 /// `(start, end)` half-open range indexes into `scan.src_bytes()`
744 /// to recover the element name. Zero allocation.
745 SourceRange(u32, u32),
746 /// Cold path — start tag read from inside an entity-replacement
747 /// stream. The entity bytes only exist while that frame is
748 /// active on the scanner, and even then a source-offset wouldn't
749 /// land on the right bytes; we own the name outright so end-tag
750 /// matching and error messages work regardless of which stream
751 /// owns the original bytes.
752 Owned(String),
753}
754
755impl ElementStackEntry {
756 /// Borrow the element-name bytes, indexing into `src` only when
757 /// this entry is a `SourceRange`.
758 fn name_bytes<'a>(&'a self, src: &'a [u8]) -> &'a [u8] {
759 match self {
760 ElementStackEntry::SourceRange(s, e) => &src[*s as usize..*e as usize],
761 ElementStackEntry::Owned(s) => s.as_bytes(),
762 }
763 }
764}
765
766pub struct XmlBytesReader<'src> {
767 scan: Scanner<'src, 'static>,
768 /// General entities (`<!ENTITY name …>`) declared in the DTD.
769 /// The value carries both the kind (internal / external) and
770 /// any replacement text we have — see [`EntityKind`]. The
771 /// kind is consulted by `&name;` expansion, by the text-decl
772 /// skip on entity push, and by §4.4.4's
773 /// "no external entity in attribute values" check.
774 entities: HashMap<String, EntityDecl>,
775 /// Parameter entities (`<!ENTITY % name …>`) declared in the
776 /// DTD. Same kind/value layout as `entities`; lives in a
777 /// separate map because PE references (`%name;`) and general
778 /// references (`&name;`) are disjoint namespaces in
779 /// XML 1.0 § 4.4. On `%name;` expansion in the internal
780 /// subset we push the replacement text as an entity stream
781 /// and let the decl / PI / comment parsers run against the
782 /// expanded bytes — naturally catching e.g. a PE that
783 /// expands to `<?xml ...?>` in the wrong context
784 /// (XML 1.0 § 2.8 [28a]).
785 parameter_entities: HashMap<String, EntityDecl>,
786 /// External *general* entities declared with a resolver configured
787 /// but not yet loaded. Populated at declaration time and drained
788 /// by `load_deferred_entity` on first `&name;` reference, so an
789 /// unreferenced external entity never triggers I/O (XML 1.0
790 /// § 4.4.3; see [`DeferredExternal`]).
791 deferred_general_entities: HashMap<String, DeferredExternal>,
792 /// XML 1.0 errata E13: when the internal DTD subset contains at
793 /// least one parameter-entity reference (`%pe;`), an undeclared
794 /// *general* entity reference becomes a validity error rather
795 /// than a well-formedness error — the PE could in principle have
796 /// declared it. Tracked here so `expand_reference_bytes` can
797 /// tolerate the missing decl when this flag is set.
798 pe_ref_in_internal_subset_seen: bool,
799 depth: u32,
800 expansion_bytes: u64,
801 /// Tracking stack for end-tag matching. Hot path stores a byte
802 /// range into the original source — zero allocation per start
803 /// tag. Cold path (start tag read from inside an entity-stream
804 /// replacement text) stores the name as an owned String, since
805 /// the entity bytes only exist while that frame is active and a
806 /// source-offset wouldn't resolve to the right bytes anyway.
807 element_stack: Vec<ElementStackEntry>,
808 /// `scan.stream_depth()` captured at each start tag. At end-tag time
809 /// the depth must match — XML 1.0 § 4.3.2 forbids start/end tag pairs
810 /// from straddling an entity boundary. Parallel to `element_stack`.
811 element_streams: Vec<usize>,
812 /// Whether each open element has had at least one child *element*
813 /// (as opposed to only text / nothing). Parallel to `element_stack`.
814 /// Used by `skip_inter_element_whitespace` (libxml2 `remove_blank_text`)
815 /// to keep a leaf element's sole whitespace content while dropping
816 /// whitespace that sits between element siblings.
817 frame_saw_child: Vec<bool>,
818 /// Folded inter-call state — see [`NextState`]. Replaces the
819 /// previous `pending_end: Option<(u32, u32)>` + `prolog_done: bool`
820 /// pair so the steady-state hot path checks one tag, not two.
821 state: NextState,
822 /// `true` once the document's root element has started. XML 1.0
823 /// § 2.1 [document]: the document body has *exactly one* root
824 /// element, and only Misc (comment / PI / whitespace) is allowed
825 /// at the document level outside it. We track this so we can
826 /// reject text events at depth 0, reject a second start tag at
827 /// depth 0 after the root closes, and reject EOF while still
828 /// nested. Disabled when `skip_end_tag_check: true` (caller
829 /// has opted out of structural well-formedness).
830 root_seen: bool,
831 /// Source byte offset of the most recently emitted StartElement's
832 /// `<` character. Updated inside `dispatch_start_element` just
833 /// before constructing the [`BytesStartTag`]. Returned by
834 /// [`last_start_offset`](Self::last_start_offset).
835 ///
836 /// Used by higher-level validators (XSD) that need the offset of
837 /// the element they just saw — the scanner's `src_offset()` is
838 /// past the start tag's `>` by then, which puts diagnostics on
839 /// the wrong line for multi-line start tags or root elements
840 /// preceded by whitespace.
841 ///
842 /// `u32::MAX` before the first start tag is emitted, and for
843 /// start tags read from inside an entity-replacement stream
844 /// (where the source offset is meaningless).
845 last_start_offset: u32,
846 /// Non-fatal errors logged while
847 /// `ParseOptions::recovery_mode` is enabled. Empty in
848 /// strict mode (errors are returned via `Err` instead). See
849 /// [`recovered_errors`](Self::recovered_errors).
850 recovered_errors: Vec<XmlError>,
851 /// XML declaration fields captured during prolog parsing. `None`
852 /// when the document had no `<?xml ... ?>` declaration; `Some`
853 /// after it has been parsed. Surfaced via
854 /// [`xml_decl`](Self::xml_decl).
855 xml_decl: Option<XmlDeclInfo>,
856 /// Mirror of `xml_decl.standalone == Some(true)` cached for cheap
857 /// access from the reference-expansion hot path. XML 1.0 § 2.9 /
858 /// § 4.1 WFC: Entity Declared — in a standalone document, refs to
859 /// entities declared in the external subset are not-WF; this bool
860 /// gates that check without re-reading `xml_decl` per reference.
861 standalone_yes: bool,
862 /// True when the document declared `version="1.1"`. Cached for
863 /// the character-class and line-ending hot paths so they can pick
864 /// the right table without paying an Option<String> compare per
865 /// byte. Stays false for unspecified versions (XML 1.0 default).
866 is_xml_11: bool,
867 /// One-shot source-wide pre-scan result: `true` iff the original
868 /// source bytes contain at least one byte that participates in
869 /// §2.11 EOL normalization — a `\r`, a UTF-8 NEL (`0xC2 0x85`),
870 /// or a UTF-8 LS (`0xE2 0x80 0xA8`). Computed once at
871 /// construction with a SIMD memchr3 sweep + a multi-byte
872 /// lookahead on each candidate.
873 ///
874 /// When `false`, the per-text-event EOL scan is skipped
875 /// entirely (a typical UTF-8 LF-only document hits this fast
876 /// path). When `true`, the per-segment scan still runs to
877 /// locate the rewrite positions.
878 source_has_eol_candidate: bool,
879 /// Reusable scratch buffer for the text-event slow path (entity
880 /// expansion, `]]>` rejection, etc.). Cleared at the start of
881 /// each slow-path entry so allocation cost amortises across all
882 /// text chunks in the document instead of paying a fresh
883 /// `Vec::new` + grow per chunk.
884 ///
885 /// Lives on the reader so its capacity grows monotonically to the
886 /// largest single text chunk seen during parsing — typical
887 /// documents end up with one allocation total across the entire
888 /// parse.
889 text_decode_buf: Vec<u8>,
890 /// DTD declarations captured from the internal subset. Populated
891 /// by `parse_element_decl` / `parse_attlist_decl` as the prolog
892 /// is consumed. Empty when the document has no doctype, or its
893 /// doctype contains no element/attlist decls. Surfaced via
894 /// [`take_dtd`](Self::take_dtd) once parsing finishes so that
895 /// downstream validators can read it.
896 dtd: crate::dtd::Dtd,
897 /// Running count of document-level comments/PIs emitted in the
898 /// prolog (`depth == 0`, before the root element opened).
899 /// Snapshotted into `dtd.internal_subset_prolog_index` when the
900 /// `<!DOCTYPE …>` is parsed, so the compat layer can place the
901 /// internal-subset node at its true position among prolog siblings.
902 prolog_misc_count: u32,
903}
904
905/// Maximum `(…)` nesting accepted in a DTD element content model
906/// (XML 1.0 § 3.2 [47]). The model is parsed by mutual recursion
907/// (`parse_content_model_inner` ⇄ `parse_cp`), so a pathologically
908/// nested declaration — `<!ELEMENT e (((((…)))))>` from an untrusted
909/// internal or external subset — would otherwise overflow the call
910/// stack. 256 levels sits far above any real grammar.
911const MAX_CONTENT_MODEL_DEPTH: u32 = 256;
912
913impl<'src> XmlBytesReader<'src> {
914 /// Create a reader from a string slice. The string must remain alive for
915 /// the lifetime of the reader and all events it produces.
916 #[allow(clippy::should_implement_trait)] // intentional: mirrors `FromStr` but `XmlBytesReader<'src>` borrows
917 pub fn from_str(input: &'src str) -> Self {
918 Self::new(input.as_bytes())
919 }
920
921 /// Create a reader from a byte slice. Returns an error if the bytes are
922 /// not valid UTF-8.
923 pub fn from_bytes(src: &'src [u8]) -> Result<Self> {
924 simdutf8::compat::from_utf8(src).map_err(|e| {
925 // `valid_up_to` is the offset of the first ill-formed
926 // byte; attach it so callers get the same location info
927 // they get from any other parse failure.
928 let off = e.valid_up_to();
929 let (line, col) = crate::scanner::compute_line_col(src, off);
930 XmlError::new(ErrorDomain::Encoding, ErrorLevel::Fatal, format!("invalid UTF-8: {e}"))
931 .at("<input>", line, col, off as u64)
932 })?;
933 Ok(Self::new(src))
934 }
935
936 /// Create a reader from a byte slice, **skipping** the upfront UTF-8
937 /// validation that [`from_bytes`](Self::from_bytes) performs.
938 ///
939 /// # Why this is faster
940 ///
941 /// [`from_bytes`](Self::from_bytes) runs a single O(n)
942 /// `std::str::from_utf8` over the entire input before any events are
943 /// produced. On large documents that pass is measurable. This entry
944 /// point removes it.
945 ///
946 /// # Just-in-time validation by the caller
947 ///
948 /// This constructor does not validate later either — the safety contract
949 /// is that the bytes already are UTF-8 when you call it. The contract
950 /// lets the caller validate *however and whenever they want*, including
951 /// not at all when the encoding is already guaranteed by the upstream
952 /// source. Common patterns:
953 ///
954 /// **Already a `&str`** — UTF-8 is guaranteed by Rust's type system:
955 ///
956 /// ```no_run
957 /// # use sup_xml_core::XmlBytesReader;
958 /// let xml: &str = "<r/>";
959 /// let reader = unsafe { XmlBytesReader::from_bytes_unchecked(xml.as_bytes()) };
960 /// ```
961 ///
962 /// **Validate up front yourself** — useful when you want a custom error
963 /// type or want to avoid duplicate work later:
964 ///
965 /// ```no_run
966 /// # use sup_xml_core::XmlBytesReader;
967 /// let bytes: &[u8] = b"<r/>";
968 /// std::str::from_utf8(bytes).expect("input must be UTF-8");
969 /// let reader = unsafe { XmlBytesReader::from_bytes_unchecked(bytes) };
970 /// ```
971 ///
972 /// **Validate each chunk as it streams in** — the per-chunk passes total
973 /// the same O(n) work as one big pass, but you can interleave validation
974 /// with I/O instead of paying it all at the end:
975 ///
976 /// ```no_run
977 /// # use sup_xml_core::XmlBytesReader;
978 /// # fn next_chunk() -> Option<Vec<u8>> { None }
979 /// let mut buf = Vec::new();
980 /// while let Some(chunk) = next_chunk() {
981 /// std::str::from_utf8(&chunk).expect("chunk must be UTF-8");
982 /// buf.extend_from_slice(&chunk);
983 /// }
984 /// let reader = unsafe { XmlBytesReader::from_bytes_unchecked(&buf) };
985 /// ```
986 ///
987 /// In all of these the upfront pass inside
988 /// [`from_bytes`](Self::from_bytes) is duplicated work, and this
989 /// constructor lets you elide it.
990 ///
991 /// # Safety
992 ///
993 /// `src` must be valid UTF-8. Passing non-UTF-8 bytes is **undefined
994 /// behaviour** — the reader hands out `&str` slices into the input that
995 /// the rest of the program will treat as UTF-8.
996 pub unsafe fn from_bytes_unchecked(src: &'src [u8]) -> Self {
997 Self::new(src)
998 }
999
1000 /// Construct a reader in **destructive in-place mode**. The
1001 /// resulting reader is permitted to mutate `src` during parsing
1002 /// (entity decoding, normalization) — the caller transfers
1003 /// exclusive write access for the reader's lifetime. Use this
1004 /// alongside `parse_bytes_in_place` in [`crate::parser`].
1005 ///
1006 /// # Safety
1007 ///
1008 /// `src` must be valid UTF-8. Same contract as
1009 /// [`from_bytes_unchecked`](Self::from_bytes_unchecked).
1010 pub unsafe fn from_bytes_in_place_unchecked(src: &'src mut [u8]) -> Self {
1011 let source_has_eol_candidate = precompute_source_has_eol(src);
1012 Self {
1013 scan: Scanner::new_in_place(src, Cow::Owned(ParseOptions::default())),
1014 entities: HashMap::default(),
1015 parameter_entities: HashMap::default(),
1016 deferred_general_entities: HashMap::default(),
1017 pe_ref_in_internal_subset_seen: false,
1018 depth: 0,
1019 expansion_bytes: 0,
1020 element_stack: Vec::new(),
1021 element_streams: Vec::new(),
1022 frame_saw_child: Vec::new(),
1023 state: NextState::NeedsProlog,
1024 root_seen: false,
1025 last_start_offset: u32::MAX,
1026 recovered_errors: Vec::new(),
1027 xml_decl: None,
1028 standalone_yes: false,
1029 is_xml_11: false,
1030 source_has_eol_candidate,
1031 text_decode_buf: Vec::new(),
1032 dtd: crate::dtd::Dtd::new(),
1033 prolog_misc_count: 0,
1034 }
1035 }
1036
1037 pub fn with_options(mut self, opts: ParseOptions) -> Self {
1038 self.scan.opts = Cow::Owned(opts);
1039 self
1040 }
1041
1042 /// Re-point the inner scanner's source view at a fresh buffer
1043 /// location. Used by the streaming reader wrapper after it has
1044 /// refilled / compacted / grown its rolling buffer.
1045 ///
1046 /// # Safety
1047 ///
1048 /// All of [`crate::scanner::Scanner::rebind`]'s safety contract
1049 /// applies, plus: this reader's `'src` lifetime parameter is a
1050 /// lie when the caller is the streaming wrapper (the wrapper
1051 /// constructs the reader with `'src = 'static` and rebinds the
1052 /// scanner to bytes that live in its own `Vec<u8>`). The
1053 /// reader must not outlive the buffer it points into, and the
1054 /// caller must call this whenever the buffer might have moved.
1055 #[inline]
1056 pub(crate) unsafe fn rebind_scanner(&mut self, ptr: *const u8, len: usize, cur_pos: usize) {
1057 // SAFETY: forwarded; see `Scanner::rebind` and the docstring above.
1058 unsafe { self.scan.rebind(ptr, len, cur_pos) }
1059 }
1060
1061 fn new(src: &'src [u8]) -> Self {
1062 let source_has_eol_candidate = precompute_source_has_eol(src);
1063 Self {
1064 scan: Scanner::new(src, Cow::Owned(ParseOptions::default())),
1065 entities: HashMap::default(),
1066 parameter_entities: HashMap::default(),
1067 deferred_general_entities: HashMap::default(),
1068 pe_ref_in_internal_subset_seen: false,
1069 depth: 0,
1070 expansion_bytes: 0,
1071 element_stack: Vec::new(),
1072 element_streams: Vec::new(),
1073 frame_saw_child: Vec::new(),
1074 state: NextState::NeedsProlog,
1075 root_seen: false,
1076 last_start_offset: u32::MAX,
1077 recovered_errors: Vec::new(),
1078 xml_decl: None,
1079 standalone_yes: false,
1080 is_xml_11: false,
1081 source_has_eol_candidate,
1082 text_decode_buf: Vec::new(),
1083 dtd: crate::dtd::Dtd::new(),
1084 prolog_misc_count: 0,
1085 }
1086 }
1087
1088 /// XML declaration fields parsed from the prolog. Returns `None`
1089 /// before the first event has been read, or if the document has no
1090 /// `<?xml ... ?>` declaration. Calling this after at least one
1091 /// successful `next()` (or `read_event`) call is guaranteed to
1092 /// reflect the document's actual declaration state.
1093 pub fn xml_decl(&self) -> Option<&XmlDeclInfo> {
1094 self.xml_decl.as_ref()
1095 }
1096
1097 /// Borrow the DTD declarations captured from the internal subset.
1098 ///
1099 /// Empty when the document had no `<!DOCTYPE … [ … ]>`, or had a
1100 /// doctype with no `<!ELEMENT>`/`<!ATTLIST>` declarations. The
1101 /// returned [`crate::dtd::Dtd`] feeds
1102 /// [`crate::dtd::validate`].
1103 pub fn dtd(&self) -> &crate::dtd::Dtd {
1104 &self.dtd
1105 }
1106
1107 /// The original source bytes the reader is parsing. Used by
1108 /// callers that need byte-offset → line/column translation for
1109 /// diagnostics or for stamping `Node::line` at element creation.
1110 #[inline]
1111 pub fn src_bytes(&self) -> &'src [u8] {
1112 self.scan.src_bytes()
1113 }
1114
1115 /// Current byte offset into the original source. Pairs with
1116 /// [`src_bytes`](Self::src_bytes) and
1117 /// [`crate::scanner::compute_line_col`] for on-demand line/column
1118 /// translation by higher-level validators (XSD, custom checkers).
1119 /// Inside an entity-replacement stream this returns the position
1120 /// of the entity reference in the user-visible document — see
1121 /// [`crate::scanner::Scanner::src_offset`].
1122 #[inline]
1123 pub fn src_offset(&self) -> usize {
1124 self.scan.src_offset()
1125 }
1126
1127 /// Source byte offset of the most recently emitted StartElement's
1128 /// `<` character. `None` before the first start tag, or for start
1129 /// tags read from inside an entity-replacement stream (where source
1130 /// offsets are meaningless).
1131 ///
1132 /// Validators snapshot this immediately after `next()` /
1133 /// `next_into()` returns a StartElement to anchor downstream
1134 /// diagnostics at the right source position — `src_offset()` by
1135 /// that point has advanced past the start tag's closing `>`,
1136 /// which puts errors on the wrong line for multi-line start tags
1137 /// or root elements preceded by whitespace.
1138 #[inline]
1139 pub fn last_start_offset(&self) -> Option<usize> {
1140 match self.last_start_offset {
1141 u32::MAX => None,
1142 v => Some(v as usize),
1143 }
1144 }
1145
1146 /// Consume `self`'s DTD, leaving an empty one behind. Used by
1147 /// `parser.rs::parse_bytes` to hand ownership over to the
1148 /// resulting [`Document`].
1149 pub fn take_dtd(&mut self) -> crate::dtd::Dtd {
1150 std::mem::take(&mut self.dtd)
1151 }
1152
1153 /// Errors logged while `ParseOptions::recovery_mode` was
1154 /// enabled. Empty in strict mode (the default) — errors there
1155 /// surface as `Err` from `next()` and abort the parse.
1156 ///
1157 /// In recover mode this list grows as the parser encounters
1158 /// non-fatal well-formedness violations and applies heuristic
1159 /// repair to keep going. Inspect after parsing finishes to
1160 /// learn what the document had wrong; or poll periodically
1161 /// during streaming.
1162 ///
1163 /// Order is the order errors were encountered. Each entry's
1164 /// `level` is `ErrorLevel::Error` (recoverable) or
1165 /// `ErrorLevel::Warning` (informational). Fatal errors are
1166 /// never logged here — they always come back through `Err`.
1167 pub fn recovered_errors(&self) -> &[XmlError] {
1168 &self.recovered_errors
1169 }
1170
1171 /// Decide whether to recover from a non-fatal error or surface
1172 /// it to the caller, based on `ParseOptions::recovery_mode`
1173 /// and the error's severity.
1174 ///
1175 /// - `Fatal` errors always return `Err`, regardless of the flag.
1176 /// - In recover mode, `Error` and `Warning` errors are pushed
1177 /// to [`recovered_errors`](Self::recovered_errors) and the
1178 /// caller continues parsing.
1179 /// - In strict mode, all errors return `Err`.
1180 #[inline]
1181 pub(crate) fn maybe_recover(&mut self, err: XmlError) -> Result<()> {
1182 if err.level == ErrorLevel::Fatal || !self.scan.opts.recovery_mode {
1183 return Err(err);
1184 }
1185 self.recovered_errors.push(err);
1186 Ok(())
1187 }
1188
1189 /// Synthesise an `EndElement` event for the topmost still-open
1190 /// element. Used by recovery for "unclosed element at EOF" and
1191 /// "mismatched end tag" — closes the inferred element so the
1192 /// caller's event stream reaches a consistent state.
1193 ///
1194 /// The synthetic close uses the start tag's name range so the
1195 /// event looks the same as a real close to consumers. When
1196 /// `skip_end_tag_check: true` (no element_stack maintained) we
1197 /// can't recover the name, so we synthesise an `EndElement`
1198 /// with an empty name range — the caller can still see depth
1199 /// going to 0 even if the name is uninformative.
1200 fn synthesize_close(&mut self) -> BytesEvent<'_, 'src> {
1201 // Resolve to a source byte range when possible; for an
1202 // entity-stream-owned name, BytesEndTag can't borrow the
1203 // owned String, so return an empty range — synthesize_close
1204 // is only invoked from recovery paths where the consumer
1205 // mostly cares about depth bookkeeping, not the precise
1206 // name bytes.
1207 let (name_start, name_end) = match self.element_stack.pop() {
1208 Some(ElementStackEntry::SourceRange(s, e)) => (s, e),
1209 Some(ElementStackEntry::Owned(_)) | None => (0, 0),
1210 };
1211 self.element_streams.pop();
1212 self.frame_saw_child.pop();
1213 self.depth = self.depth.saturating_sub(1);
1214 let src = self.scan.src_bytes();
1215 BytesEvent::EndElement(BytesEndTag { src, name_start, name_end })
1216 }
1217
1218 // ── public API ────────────────────────────────────────────────────────────
1219
1220 /// Read the next event with **lazy** attribute access.
1221 ///
1222 /// Returns an [`BytesEvent`] borrowing the reader for its lifetime. Start tag
1223 /// events carry an [`BytesAttrs`] iterator — iterate it to read attributes,
1224 /// ignore it to skip attribute parsing entirely.
1225 ///
1226 /// For an eager API that fills a caller-owned buffer with parsed
1227 /// attributes, see [`next_into`](Self::next_into).
1228 #[allow(clippy::should_implement_trait)] // can't impl `Iterator`: events borrow the reader
1229 pub fn next(&mut self) -> Result<BytesEvent<'_, 'src>> {
1230 // Steady-state fast path is the first match arm. The branch
1231 // predictor sees the same arm on >99% of calls in any non-
1232 // pathological document; the cold arms self-transition back to
1233 // `Steady` so the predictor stays correct.
1234 match self.state {
1235 NextState::Steady => {}
1236 NextState::PendingEnd(name_start, name_end) => {
1237 // Source-relative offsets — empty-element close was
1238 // queued by `read_start_element`, where the scanner was
1239 // on the original source. Drop back to Steady before
1240 // returning so the next call hits the hot arm.
1241 self.state = NextState::Steady;
1242 let src = self.scan.src_bytes();
1243 return Ok(BytesEvent::EndElement(BytesEndTag {
1244 src, name_start, name_end,
1245 }));
1246 }
1247 NextState::PendingEntityRef(name_start, name_end) => {
1248 // Text-content loop saw an unresolved `&name;` and
1249 // bailed; emit the queued EntityRef event now.
1250 self.state = NextState::Steady;
1251 let src = self.scan.src_bytes();
1252 return Ok(BytesEvent::EntityRef(BytesEntityRef {
1253 src, name_start, name_end,
1254 }));
1255 }
1256 NextState::NeedsProlog => {
1257 self.parse_prolog()?;
1258 self.state = NextState::Steady;
1259 }
1260 }
1261
1262 // ── hot dispatch: cursor in locals ──────────────────────────
1263 //
1264 // The hot dispatch holds the cursor in stack locals
1265 // (`bytes`, `end`, `p`) so LLVM can register-allocate them
1266 // across the per-event dispatch instead of reloading from the
1267 // Scanner on every method call. This is correct ONLY when
1268 // the active stream is the original source — when an entity
1269 // expansion has pushed a replacement-text stream, `cur_pos`
1270 // is relative to the entity bytes, not `src_bytes()`, and
1271 // mixing the two reads from the wrong buffer. Bail to the
1272 // method-based dispatch in that case.
1273 if !self.scan.on_original_source() {
1274 return self.next_in_entity();
1275 }
1276 let bytes = self.scan.src_bytes();
1277 let end = bytes.len();
1278 let p_in = self.scan.cur_pos();
1279 let mut p = p_in;
1280
1281 // Skip whitespace. Always at depth 0 (between top-level
1282 // constructs); deeper depths only when the caller opts in via
1283 // `ParseOptions::skip_inter_element_whitespace`.
1284 //
1285 // SAFETY (all the `get_unchecked` below): `p`/`q` are bounded by
1286 // `< end` and `end == bytes.len()`. Hand-bounded to keep this
1287 // per-event loop branch-light; see CONTRIBUTING.md § "Unsafe policy".
1288 #[inline(always)]
1289 fn is_xml_ws(b: u8) -> bool {
1290 b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
1291 }
1292 if self.depth == 0 {
1293 // Top-level: whitespace between the prolog/root/misc is never
1294 // part of any element's content — always skip it.
1295 while p < end && is_xml_ws(unsafe { *bytes.get_unchecked(p) }) {
1296 p += 1;
1297 }
1298 } else if self.scan.opts.skip_inter_element_whitespace {
1299 // libxml2 `remove_blank_text` (areBlanks): a whitespace-only
1300 // run is "ignorable" — and dropped — only when it sits between
1301 // element siblings. Peek the run, then decide from what
1302 // follows it, leaving `p` put (so the run is read as text)
1303 // when it should be kept.
1304 let mut q = p;
1305 while q < end && is_xml_ws(unsafe { *bytes.get_unchecked(q) }) {
1306 q += 1;
1307 }
1308 if q > p {
1309 let drop = match bytes.get(q).copied() {
1310 // Followed by an end tag: ignorable only if this
1311 // element already holds a child element; a leaf
1312 // element's sole whitespace content is significant.
1313 Some(b'<') if bytes.get(q + 1) == Some(&b'/') => {
1314 self.frame_saw_child.last().copied().unwrap_or(false)
1315 }
1316 // Followed by another element (or comment / PI):
1317 // inter-element whitespace, ignorable.
1318 Some(b'<') => true,
1319 // Followed by character data (or EOF): this is the
1320 // leading whitespace of a non-blank text node — keep
1321 // it, never strip prose.
1322 _ => false,
1323 };
1324 if drop {
1325 p = q;
1326 }
1327 }
1328 }
1329
1330 // EOF — element content has no entity streams in this path,
1331 // so `is_eof()` reduces to a simple bounds check.
1332 if p >= end {
1333 self.scan.cur_set_pos(p);
1334 // XML 1.0 § 3.1: every start tag must have a matching
1335 // end tag before the document ends. Reject if the
1336 // element stack is still open. Gated on the same
1337 // skip_end_tag_check flag that disables paired-name
1338 // matching, since both checks are about structural
1339 // well-formedness and a caller streaming fragments will
1340 // want both relaxed together.
1341 if self.depth > 0 && !self.scan.opts.skip_end_tag_check {
1342 // Recovery: synthesise an EndElement event for the
1343 // topmost open element, log a per-element error, and
1344 // return. Subsequent next() calls land here again
1345 // until depth == 0, then we fall through to Eof —
1346 // the caller sees a clean tree close-out and the
1347 // recovered_errors list itemises which elements
1348 // were unclosed.
1349 //
1350 // SAFETY: indexing element_stack — guarded by the
1351 // `depth > 0` precondition AND the fact that
1352 // !skip_end_tag_check means the stack is maintained
1353 // in lockstep with depth. The element_stack pop
1354 // happens inside synthesize_close, so we read here
1355 // before mutating.
1356 let name_lossy = self.element_stack.last()
1357 .map(|e| String::from_utf8_lossy(e.name_bytes(bytes)).into_owned())
1358 .unwrap_or_else(|| "?".to_string());
1359 let err = self.scan.err_with_level(
1360 ErrorLevel::Error,
1361 format!(
1362 "unclosed element '<{name_lossy}>' at end of document \
1363 (XML 1.0 § 3.1 [STag/ETag])"
1364 ),
1365 ).with_code(crate::error::ErrorCode::TagNotFinished);
1366 self.maybe_recover(err)?;
1367 return Ok(self.synthesize_close());
1368 }
1369 // XML 1.0 § 2.1 [document] = prolog element Misc*.
1370 // The single root [element] is REQUIRED; an empty
1371 // document (one with only whitespace / comments / a
1372 // DOCTYPE) is not well-formed.
1373 if !self.root_seen && !self.scan.opts.skip_end_tag_check {
1374 let err = self.scan.err_with_level(
1375 ErrorLevel::Error,
1376 "document has no root element (XML 1.0 § 2.1 [document])",
1377 ).with_code(crate::error::ErrorCode::DocumentEmpty);
1378 self.maybe_recover(err)?;
1379 // Nothing to synthesise — empty doc stays empty.
1380 }
1381 return Ok(BytesEvent::Eof);
1382 }
1383
1384 // SAFETY: the `if p >= end { return Eof }` above proves `p <
1385 // end == bytes.len()` here, so `bytes.get_unchecked(p)` is
1386 // in bounds.
1387 // Why unsafe: dispatched on every event; bounds check would
1388 // run per call. See CONTRIBUTING.md § "Unsafe policy".
1389 let b0 = unsafe { *bytes.get_unchecked(p) };
1390
1391 if b0 != b'<' {
1392 // XML 1.0 § 2.1 [document]: text is forbidden at the
1393 // document level — only Misc (whitespace / comments /
1394 // PIs) is allowed outside the root element. Whitespace
1395 // was already consumed by the depth-0 skip-ws above, so
1396 // any non-`<` byte at depth 0 here is real text content
1397 // appearing illegally outside a root.
1398 if self.depth == 0 && !self.scan.opts.skip_end_tag_check {
1399 let err = self.scan.err_with_level(
1400 ErrorLevel::Error,
1401 "text content not allowed at the document level \
1402 (XML 1.0 § 2.1 [document])",
1403 );
1404 self.maybe_recover(err)?;
1405 // Recovery: emit the doc-level text as a Text event
1406 // so the user can see what was there. Better than
1407 // libxml2 which sometimes silently loses the root
1408 // element OR the trailing text depending on
1409 // position. read_text scans up to the next `<` or
1410 // EOF.
1411 }
1412 // Text-content path: write the cursor back and let the
1413 // existing slow path handle entity references and the
1414 // `]]>` check. Skip the store when the local cursor
1415 // didn't actually advance (no whitespace consumed at
1416 // depth > 0 — the common in-element case).
1417 if p != p_in {
1418 self.scan.cur_set_pos(p);
1419 }
1420 return self.read_text();
1421 }
1422
1423 // ── single-load `<x` dispatch ───────────────────────────────
1424 //
1425 // Replaces four serial `starts_with` calls (each loading
1426 // `cur_ptr`/`cur_len` and comparing 2-9 bytes) with one byte
1427 // load and a small jump table. The `read_*` methods re-validate
1428 // the prefix via `expect_str`, so a mis-dispatch on a malformed
1429 // input still produces a fatal error — just from a slightly
1430 // different call site.
1431 let b1 = if p + 1 < end {
1432 // SAFETY: the `if p + 1 < end` guard proves `p + 1 <
1433 // bytes.len()`.
1434 // Why unsafe: dispatched on every `<` we encounter; this
1435 // is the per-tag dispatch byte read. See CONTRIBUTING.md
1436 // § "Unsafe policy".
1437 unsafe { *bytes.get_unchecked(p + 1) }
1438 } else {
1439 0
1440 };
1441 // Skip the writeback when no whitespace was consumed — common
1442 // case inside an element (depth > 0) where p == p_in.
1443 if p != p_in {
1444 self.scan.cur_set_pos(p);
1445 }
1446 match b1 {
1447 b'/' => self.read_end_element(),
1448 b'?' => self.read_pi(),
1449 b'!' => {
1450 // `<!` either opens a comment (`<!--`), CDATA
1451 // (`<![CDATA[`), or — at the document level only —
1452 // a DOCTYPE declaration (`<!DOCTYPE …>`). The old
1453 // prolog handler ate DOCTYPE before any user-visible
1454 // event, but with comments now emitted as events
1455 // (rather than silently skipped in the prolog), a
1456 // DOCTYPE that follows a comment lands back in this
1457 // dispatch loop — so it has to be handled here too.
1458 // Anything else is malformed and falls through to
1459 // `dispatch_start_element` so the existing
1460 // name-validation error fires (matches the
1461 // pre-refactor dispatch behaviour for inputs like
1462 // `<!X`).
1463 let b2 = if p + 2 < end {
1464 // SAFETY: the `if p + 2 < end` guard proves `p +
1465 // 2 < bytes.len()`.
1466 // Why unsafe: same per-tag-dispatch hot path as
1467 // `b1` above. See CONTRIBUTING.md § "Unsafe
1468 // policy".
1469 unsafe { *bytes.get_unchecked(p + 2) }
1470 } else {
1471 0
1472 };
1473 match b2 {
1474 b'-' => self.read_comment(),
1475 b'[' => {
1476 // CDATA sections are part of [content], so they
1477 // are only legal *inside* an element (depth > 0).
1478 // At the document level they're a fatal error
1479 // (XML 1.0 § 2.1 [document] / § 3.1 [content]).
1480 if self.depth == 0
1481 && !self.scan.opts.skip_end_tag_check
1482 {
1483 return Err(self.scan.err(
1484 "CDATA sections are only allowed inside an element \
1485 (XML 1.0 § 3.1 [content])"
1486 ));
1487 }
1488 self.read_cdata()
1489 }
1490 b'D' | b'd' if self.depth == 0
1491 && (self.scan.starts_with(b"<!DOCTYPE")
1492 || self.scan.starts_with(b"<!doctype")) =>
1493 {
1494 // Consume the DOCTYPE in-place and then
1495 // recurse to pick up the next real event.
1496 // `parse_doctype` already returns after the
1497 // closing `]>` so the cursor is positioned
1498 // at the post-DOCTYPE byte.
1499 self.parse_doctype()?;
1500 self.next()
1501 }
1502 _ => self.dispatch_start_element(),
1503 }
1504 }
1505 _ => {
1506 // Bare `<` recovery: a `<` followed by something
1507 // that isn't a NameStartChar (whitespace, digit,
1508 // EOF, etc.) can't open a real start tag. In
1509 // recover mode, treat the `<` as literal text and
1510 // continue. Preserves user data — unlike libxml2
1511 // which silently drops the `<` from the text
1512 // payload.
1513 let looks_like_name_start = matches!(
1514 b1,
1515 b'A'..=b'Z' | b'a'..=b'z' | b'_' | b':' | 0x80..=0xFF
1516 );
1517 if !looks_like_name_start
1518 && self.scan.opts.recovery_mode
1519 && self.depth > 0
1520 {
1521 let err = self.scan.err_with_level(
1522 ErrorLevel::Error,
1523 "bare '<' in text content — kept literal \
1524 (XML 1.0 § 2.4 [CharData])",
1525 );
1526 self.recovered_errors.push(err);
1527 // Emit a Text("<") event and advance past the
1528 // `<`; the next event will pick up at the
1529 // following byte. This produces an event
1530 // stream like Text("1 "), Text("<"), Text(" 2")
1531 // for input `<r>1 < 2</r>` — the caller can
1532 // concatenate text events to recover the
1533 // original bytes.
1534 self.scan.cur_set_pos(p + 1);
1535 // Manufacture a Text event by slicing the one
1536 // `<` byte directly out of `src_bytes()` — no
1537 // allocation.
1538 let src = self.scan.src_bytes();
1539 let lt_slice = &src[p..p + 1];
1540 return Ok(BytesEvent::Text(BytesText {
1541 inner: std::borrow::Cow::Borrowed(lt_slice),
1542 }));
1543 }
1544 self.dispatch_start_element()
1545 }
1546 }
1547 }
1548
1549 /// Slow-path dispatch used when an entity-replacement stream is
1550 /// active. The local-cursor fast path in `next()` reads
1551 /// `bytes = src_bytes()` and `p = cur_pos()`, which is wrong
1552 /// when an entity is being expanded (cur_pos is relative to the
1553 /// entity bytes, not the original source). This method uses
1554 /// the small-method scanner API throughout — slower, correct,
1555 /// and rare (only fires inside entity content). Always called
1556 /// with depth > 0 (we entered the entity inside an element), so
1557 /// the document-level structural checks in the fast path don't
1558 /// apply here.
1559 fn next_in_entity(&mut self) -> Result<BytesEvent<'_, 'src>> {
1560 if self.scan.opts.skip_inter_element_whitespace {
1561 self.scan.skip_ws();
1562 }
1563 // The active entity stream may be fully consumed. Pop it
1564 // so the next event is read from the parent stream below.
1565 // XML 1.0 § 4.3.2 WFC 'Logical Structure': the
1566 // element-stack depth at the entity's current position
1567 // must equal the depth captured when it was pushed —
1568 // otherwise the entity's replacement text contains
1569 // unbalanced markup and is rejected.
1570 while self.scan.cur_pos() >= self.scan.cur_len()
1571 && !self.scan.on_original_source()
1572 {
1573 let depth_now = self.element_stack.len() as u32;
1574 if let Some((name, depth_at_push)) = self.scan.top_entity_info() {
1575 if depth_at_push != depth_now {
1576 return Err(self.scan.err(format!(
1577 "entity '&{name};' contains unbalanced element markup — \
1578 element-stack depth was {depth_at_push} when the entity \
1579 was expanded but is {depth_now} at its end \
1580 (XML 1.0 § 4.3.2 WFC 'Logical Structure')"
1581 )));
1582 }
1583 }
1584 if !self.scan.try_pop_entity_stream() {
1585 break;
1586 }
1587 }
1588 // After popping we may now be on the original source —
1589 // re-enter `next()` to take the fast path.
1590 if self.scan.on_original_source() {
1591 return self.next();
1592 }
1593 if self.scan.is_eof() {
1594 // We're inside an entity stream; if we hit document EOF
1595 // here, the entity straddled a structural boundary. The
1596 // depth check at fast-path EOF doesn't fire because we
1597 // never returned to the original source; surface the
1598 // error here instead. In recover mode, synthesise a
1599 // close just as the fast-path EOF does.
1600 if self.depth > 0 && !self.scan.opts.skip_end_tag_check {
1601 let bytes = self.scan.src_bytes();
1602 let name_lossy = self.element_stack.last()
1603 .map(|e| String::from_utf8_lossy(e.name_bytes(bytes)).into_owned())
1604 .unwrap_or_else(|| "?".to_string());
1605 let err = self.scan.err_with_level(
1606 ErrorLevel::Error,
1607 format!(
1608 "unclosed element '<{name_lossy}>' at end of document \
1609 (XML 1.0 § 3.1 [STag/ETag])"
1610 ),
1611 ).with_code(crate::error::ErrorCode::TagNotFinished);
1612 self.maybe_recover(err)?;
1613 return Ok(self.synthesize_close());
1614 }
1615 return Ok(BytesEvent::Eof);
1616 }
1617 if self.scan.peek() != Some(b'<') {
1618 return self.read_text();
1619 }
1620 if self.scan.starts_with(b"</") { self.read_end_element() }
1621 else if self.scan.starts_with(b"<!--") { self.read_comment() }
1622 else if self.scan.starts_with(b"<![CDATA[") { self.read_cdata() }
1623 else if self.scan.starts_with(b"<?") { self.read_pi() }
1624 else { self.dispatch_start_element() }
1625 }
1626
1627 /// Wrapper around `read_start_element` that enforces XML 1.0
1628 /// § 2.1 [document]: at the document level, exactly one root
1629 /// element is allowed. A second start tag at depth 0 after
1630 /// the root element has closed is a fatal error. Gated on
1631 /// `!skip_end_tag_check` (callers who relax end-tag pairing
1632 /// have opted out of structural checks).
1633 #[inline]
1634 fn dispatch_start_element(&mut self) -> Result<BytesEvent<'_, 'src>> {
1635 if self.depth == 0
1636 && self.root_seen
1637 && !self.scan.opts.skip_end_tag_check
1638 {
1639 // XML 1.0 § 2.1 [document]: exactly one root element.
1640 // In recover mode, log the violation and accept the
1641 // second root anyway — the caller can still walk the
1642 // events. The resulting event stream is no longer a
1643 // single-rooted document, which the caller should be
1644 // aware of via `recovered_errors()`.
1645 let err = self.scan.err_with_level(
1646 ErrorLevel::Error,
1647 "only one root element allowed (XML 1.0 § 2.1 [document])",
1648 );
1649 self.maybe_recover(err)?;
1650 }
1651 self.read_start_element()
1652 }
1653
1654 /// Read the next event, eagerly parsing start-tag attributes into `buf`.
1655 ///
1656 /// `buf` is cleared on every call. For `StartElement` events `buf` is
1657 /// filled with the element's attributes in source order; for other events
1658 /// `buf` is left empty. Pass the same `Vec` across many calls to reuse
1659 /// its allocation.
1660 ///
1661 /// For lazy attribute access (zero work when you never read attrs), see
1662 /// [`next`](Self::next).
1663 pub fn next_into(&mut self, buf: &mut Vec<BytesAttr<'src>>) -> Result<BytesEventInto<'src>> {
1664 buf.clear();
1665 match self.next()? {
1666 BytesEvent::StartElement(tag) => {
1667 let name = tag.name_cow();
1668 for attr in tag.attrs() {
1669 buf.push(attr?);
1670 }
1671 Ok(BytesEventInto::StartElement { name })
1672 }
1673 BytesEvent::EndElement(tag) => Ok(BytesEventInto::EndElement {
1674 name: Cow::Borrowed(tag.name()),
1675 }),
1676 BytesEvent::Text(t) => Ok(BytesEventInto::Text(t.into_bytes())),
1677 BytesEvent::CData(s) => Ok(BytesEventInto::CData(s.into_bytes())),
1678 BytesEvent::Comment(s) => Ok(BytesEventInto::Comment(s.into_bytes())),
1679 BytesEvent::Pi(pi) => {
1680 let (target, content) = pi.into_parts();
1681 Ok(BytesEventInto::Pi { target, content })
1682 }
1683 BytesEvent::EntityRef(e) => Ok(BytesEventInto::EntityRef {
1684 name: Cow::Borrowed(e.name()),
1685 }),
1686 BytesEvent::Eof => Ok(BytesEventInto::Eof),
1687 }
1688 }
1689
1690 // ── prolog ────────────────────────────────────────────────────────────────
1691
1692 fn parse_prolog(&mut self) -> Result<()> {
1693 // XML 1.0 § 2.2: validate every byte once before streaming begins.
1694 // One SWAR sweep here is faster than folding the check into every
1695 // byte-consuming hot path (the bulk pass amortizes SIMD setup over
1696 // the whole document; per-content-slice calls re-pay fixed overhead
1697 // and don't fit enough bytes in their SWAR loop on short slices).
1698 if !self.scan.opts.skip_xml_char_validation {
1699 validate_xml_chars(self.scan.cur_bytes())?;
1700 }
1701 if self.scan.starts_with(&[0xEF, 0xBB, 0xBF]) { self.scan.skip_n(3); }
1702 if self.scan.starts_with(b"<?xml")
1703 && matches!(self.scan.peek_at(5), Some(b' ' | b'\t' | b'\r' | b'\n' | b'?'))
1704 {
1705 // Recovery: a malformed XML declaration (missing
1706 // version, bad value, etc.) is logged; we then scan
1707 // forward to the next `?>` and continue with the
1708 // rest of the document. Matches libxml2's behaviour
1709 // (silent skip past the bad decl).
1710 if let Err(e) = self.skip_xml_decl() {
1711 if e.level == ErrorLevel::Fatal || !self.scan.opts.recovery_mode {
1712 return Err(e);
1713 }
1714 self.recovered_errors.push(e);
1715 // Resync to the closing `?>`. If we don't find
1716 // one, give up — the input is structurally weird
1717 // beyond what our heuristic can repair.
1718 match memchr(b'?', self.scan.cur_tail()) {
1719 Some(off) => {
1720 self.scan.cur_advance_pos(off);
1721 if self.scan.starts_with(b"?>") {
1722 self.scan.skip_n(2);
1723 } else {
1724 self.scan.advance();
1725 }
1726 }
1727 None => {
1728 // No `?` at all in the rest of the input —
1729 // can't safely resync. Fall through to
1730 // skip_misc which will end at the next
1731 // structural token (or EOF).
1732 }
1733 }
1734 }
1735 }
1736 self.skip_misc()
1737 }
1738
1739 fn skip_xml_decl(&mut self) -> Result<()> {
1740 // XML 1.0 § 2.8 [XMLDecl]:
1741 // '<?xml' VersionInfo EncodingDecl? SDDecl? S? '?>'
1742 // VersionInfo ::= S 'version' Eq ("'" VersionNum "'" | '"' VersionNum '"')
1743 // EncodingDecl ::= S 'encoding' Eq ('"' EncName "'" | "'" EncName "'")
1744 // SDDecl ::= S 'standalone' Eq (("'" ('yes'|'no') "'") | ('"' ('yes'|'no') '"'))
1745 // The S between each attribute is REQUIRED, and each value
1746 // has its own production we must validate against.
1747 self.scan.expect_str(b"<?xml")?;
1748 self.scan.skip_ws();
1749
1750 // ── required: VersionInfo ────────────────────────────────
1751 if !self.scan.starts_with(b"version") {
1752 return Err(self.scan.err_with_level(
1753 ErrorLevel::Error,
1754 "XML declaration is missing the required `version` attribute \
1755 (XML 1.0 § 2.8 [XMLDecl])"
1756 ));
1757 }
1758 let version = self.consume_xmldecl_attr_value(b"version")?;
1759 // VersionNum = '1.' [0-9]+ — bytes only, no internal
1760 // whitespace. Most documents say "1.0" or "1.1".
1761 if !is_valid_version(&version) {
1762 return Err(self.scan.err_with_level(
1763 ErrorLevel::Error,
1764 format!(
1765 "invalid XML version '{}' (XML 1.0 § 2.8 [26] [VersionNum])",
1766 String::from_utf8_lossy(&version)
1767 ),
1768 ));
1769 }
1770
1771 let mut encoding_bytes: Option<Vec<u8>> = None;
1772 let mut standalone_bool: Option<bool> = None;
1773
1774 // ── optional: EncodingDecl ────────────────────────────────
1775 // S is required between attributes when both are present.
1776 // `saw_ws` records the whitespace between `version="..."`
1777 // and the next attribute (whichever it is). When encoding
1778 // is omitted, this same flag carries over to the standalone
1779 // check — re-skipping below would consume zero bytes and
1780 // falsely report "expected whitespace before standalone"
1781 // for inputs like `<?xml version='1.0' standalone='yes'?>`.
1782 let mut saw_ws = self.scan_skip_ws_returning_count() > 0;
1783 if self.scan.starts_with(b"encoding") {
1784 if !saw_ws {
1785 return Err(self.scan.err_with_level(
1786 ErrorLevel::Error,
1787 "expected whitespace before `encoding` in XML declaration \
1788 (XML 1.0 § 2.8 [XMLDecl])"
1789 ));
1790 }
1791 let enc = self.consume_xmldecl_attr_value(b"encoding")?;
1792 if !is_valid_encname(&enc) {
1793 return Err(self.scan.err_with_level(
1794 ErrorLevel::Error,
1795 format!(
1796 "invalid encoding name '{}' (XML 1.0 § 4.3.3 [81] [EncName])",
1797 String::from_utf8_lossy(&enc)
1798 ),
1799 ));
1800 }
1801 encoding_bytes = Some(enc);
1802 // Encoding consumed — refresh `saw_ws` for the standalone
1803 // check, which now needs its own preceding whitespace.
1804 saw_ws = self.scan_skip_ws_returning_count() > 0;
1805 }
1806
1807 // ── optional: SDDecl ──────────────────────────────────────
1808 if self.scan.starts_with(b"standalone") {
1809 if !saw_ws {
1810 return Err(self.scan.err(
1811 "expected whitespace before `standalone` in XML declaration \
1812 (XML 1.0 § 2.8 [XMLDecl])"
1813 ));
1814 }
1815 let sd = self.consume_xmldecl_attr_value(b"standalone")?;
1816 standalone_bool = match &sd[..] {
1817 b"yes" => Some(true),
1818 b"no" => Some(false),
1819 _ => return Err(self.scan.err(format!(
1820 "invalid 'standalone' value '{}' — must be \"yes\" or \"no\" \
1821 (XML 1.0 § 2.9 [32] [SDDecl])",
1822 String::from_utf8_lossy(&sd)
1823 ))),
1824 };
1825 }
1826
1827 self.scan.skip_ws();
1828 self.scan.expect_str(b"?>")?;
1829
1830 // Capture for callers (arena Document, serializer round-trip).
1831 // All three fields are guaranteed-valid ASCII at this point —
1832 // is_valid_version / is_valid_encname / b"yes"|b"no" enforce that.
1833 // Stash a fast version-test flag so downstream parsing code
1834 // can branch on 1.0 vs 1.1 semantics (NEL/LS line-ending
1835 // normalization, C0 character-reference acceptance, expanded
1836 // name-character ranges) without an Option<String> compare on
1837 // every check.
1838 self.is_xml_11 = version.as_slice() == b"1.1";
1839 self.xml_decl = Some(XmlDeclInfo {
1840 version: String::from_utf8(version).expect("validated ASCII"),
1841 encoding: encoding_bytes.map(|b| String::from_utf8(b).expect("validated ASCII")),
1842 standalone: standalone_bool,
1843 });
1844 self.standalone_yes = standalone_bool == Some(true);
1845 Ok(())
1846 }
1847
1848 /// Skip whitespace and report how many bytes were consumed.
1849 /// Used inside the XML declaration where some inter-attribute S
1850 /// is required and we need to know whether any was actually
1851 /// present to emit a precise error.
1852 fn scan_skip_ws_returning_count(&mut self) -> usize {
1853 let before = self.scan.cur_pos();
1854 self.scan.skip_ws();
1855 self.scan.cur_pos() - before
1856 }
1857
1858 /// Consume one `name = "value"` attribute pair inside the XML
1859 /// declaration and return the (raw) value bytes. Caller has
1860 /// already verified `starts_with(name)`; we advance past the
1861 /// name then parse `S? '=' S? AttValue`.
1862 fn consume_xmldecl_attr_value(&mut self, name: &[u8]) -> Result<Vec<u8>> {
1863 self.scan.skip_n(name.len());
1864 self.scan.skip_ws();
1865 self.scan.expect(b'=')?;
1866 self.scan.skip_ws();
1867 let q = match self.scan.advance() {
1868 Some(b @ (b'"' | b'\'')) => b,
1869 _ => return Err(self.scan.err("expected quoted XML-decl value")),
1870 };
1871 let val_start = self.scan.cur_pos();
1872 match memchr(q, self.scan.cur_tail()) {
1873 None => Err(self.scan.err("unterminated XML-decl value")),
1874 Some(off) => {
1875 let end = val_start + off;
1876 let bytes = self.scan.cur_slice(val_start, end).into_owned();
1877 self.scan.cur_set_pos(end + 1);
1878 Ok(bytes)
1879 }
1880 }
1881 }
1882
1883 fn skip_misc(&mut self) -> Result<()> {
1884 // Only structurally-significant items still consume bytes
1885 // here (DOCTYPE, leading whitespace). Comments and PIs are
1886 // left in the stream so the main `next()` dispatch can emit
1887 // them as `BytesEvent::Comment` / `BytesEvent::Pi` — this
1888 // is what lets consumers see prolog markup
1889 // (`<!--…--><root/>`) in document order. Before this
1890 // change, `skip_comment_raw` ate the bytes silently and
1891 // the prolog comment was lost.
1892 loop {
1893 self.scan.skip_ws();
1894 if self.scan.starts_with(b"<!DOCTYPE") || self.scan.starts_with(b"<!doctype") {
1895 self.parse_doctype()?;
1896 } else {
1897 break;
1898 }
1899 }
1900 Ok(())
1901 }
1902
1903 fn skip_quoted(&mut self) -> Result<()> {
1904 let q = match self.scan.advance() {
1905 Some(b @ (b'"' | b'\'')) => b,
1906 _ => return Err(self.scan.err("expected quoted value")),
1907 };
1908 // SIMD-fast jump to the closing quote — beats the byte-by-byte
1909 // peek/advance loop on long literals (DOCTYPE PUBLIC / SYSTEM
1910 // URLs are typically 50-100 chars).
1911 match memchr(q, self.scan.cur_tail()) {
1912 None => Err(self.scan.err("unterminated quoted value")),
1913 Some(off) => {
1914 self.scan.cur_advance_pos(off + 1);
1915 Ok(())
1916 }
1917 }
1918 }
1919
1920 /// Variant of [`skip_quoted`] that returns the literal contents
1921 /// instead of discarding them. Used by `parse_doctype` to
1922 /// capture the SYSTEM identifier when external-subset loading
1923 /// is enabled.
1924 fn capture_quoted(&mut self) -> Result<String> {
1925 let q = match self.scan.advance() {
1926 Some(b @ (b'"' | b'\'')) => b,
1927 _ => return Err(self.scan.err("expected quoted value")),
1928 };
1929 match memchr(q, self.scan.cur_tail()) {
1930 None => Err(self.scan.err("unterminated quoted value")),
1931 Some(off) => {
1932 let bytes = self.scan.cur_slice(self.scan.cur_pos(), self.scan.cur_pos() + off);
1933 let s = String::from_utf8_lossy(&bytes).into_owned();
1934 self.scan.cur_advance_pos(off + 1);
1935 Ok(s)
1936 }
1937 }
1938 }
1939
1940 /// Like [`skip_quoted`] but also validates the literal content
1941 /// against the rules for an XML SystemLiteral / URI:
1942 /// XML 1.0 § 4.2.2 [11] forbids `#` fragment identifiers in
1943 /// SystemLiterals (the spec says implementations may issue an
1944 /// error or warning if the SystemLiteral is not a properly
1945 /// formed URI reference; well-formed URI references in this
1946 /// context exclude `#fragment`). This is what catches
1947 /// `<!ENTITY foo SYSTEM "foo#bar">`.
1948 /// Read a quoted SystemLiteral (XML 1.0 § 4.2.2 [11]) and
1949 /// return its bytes (without the surrounding quotes). Used
1950 /// by entity-decl parsing when an `external_resolver` is
1951 /// configured — we need the URL to pass to the resolver. Used by entity-decl
1952 /// parsing when an `external_resolver` is configured — we need
1953 /// the URL to pass to the resolver.
1954 fn read_system_literal(&mut self) -> Result<String> {
1955 let q = match self.scan.advance() {
1956 Some(b @ (b'"' | b'\'')) => b,
1957 _ => return Err(self.scan.err("expected quoted SystemLiteral")),
1958 };
1959 let start = self.scan.cur_pos();
1960 match memchr(q, self.scan.cur_tail()) {
1961 None => Err(self.scan.err("unterminated SystemLiteral")),
1962 Some(off) => {
1963 let end = start + off;
1964 let bytes = &self.scan.cur_bytes()[start..end];
1965 if memchr(b'#', bytes).is_some() {
1966 return Err(self.scan.err(
1967 "URI fragment ('#…') is not allowed in a SystemLiteral \
1968 (XML 1.0 § 4.2.2 [11])"
1969 ));
1970 }
1971 // SAFETY: bytes are sourced from a Scanner whose
1972 // input is guaranteed UTF-8. Why unsafe: avoids
1973 // re-validating UTF-8 we already know is good.
1974 let s = unsafe { std::str::from_utf8_unchecked(bytes) }.to_string();
1975 self.scan.cur_advance_pos(off + 1);
1976 Ok(s)
1977 }
1978 }
1979 }
1980
1981 /// Read a quoted PubidLiteral and return its bytes as a
1982 /// String. Caller has already validated PubidChar via
1983 /// `skip_pubid_literal` semantics — we reuse that path then
1984 /// simply return the captured slice.
1985 fn read_pubid_literal(&mut self) -> Result<String> {
1986 // Save cursor, run the validating skip, then re-extract the
1987 // literal bytes from the source. Cheap because pubid
1988 // literals are short.
1989 let q = match self.scan.peek() {
1990 Some(b @ (b'"' | b'\'')) => b,
1991 _ => return Err(self.scan.err("expected quoted PubidLiteral")),
1992 };
1993 // Skip past opening quote.
1994 self.scan.advance();
1995 let start = self.scan.cur_pos();
1996 // Scan to closing quote, validating as in skip_pubid_literal.
1997 let off = memchr(q, self.scan.cur_tail())
1998 .ok_or_else(|| self.scan.err("unterminated PubidLiteral"))?;
1999 let end = start + off;
2000 let bytes = &self.scan.cur_bytes()[start..end];
2001 for &b in bytes {
2002 if !is_pubid_char(b) {
2003 return Err(self.scan.err(format!("invalid PubidChar 0x{b:02X}")));
2004 }
2005 }
2006 // SAFETY: PubidChar is a subset of ASCII (validated above);
2007 // ASCII is valid UTF-8. Why unsafe: skip the redundant
2008 // from_utf8 pass.
2009 let s = unsafe { std::str::from_utf8_unchecked(bytes) }.to_string();
2010 self.scan.cur_advance_pos(off + 1);
2011 Ok(s)
2012 }
2013
2014 fn skip_comment_raw(&mut self) -> Result<()> {
2015 self.scan.expect_str(b"<!--")?;
2016 loop {
2017 match memchr(b'-', self.scan.cur_tail()) {
2018 None => return Err(self.scan.err("unterminated comment")),
2019 Some(off) => {
2020 self.scan.cur_advance_pos(off);
2021 if self.scan.starts_with(b"-->") { self.scan.skip_n(3); return Ok(()); }
2022 if self.scan.starts_with(b"--") { return Err(self.scan.err("'--' inside comment not allowed")); }
2023 self.scan.advance();
2024 }
2025 }
2026 }
2027 }
2028
2029 fn skip_pi_raw(&mut self) -> Result<()> {
2030 self.scan.expect_str(b"<?")?;
2031 // XML 1.0 § 2.6 [16] [17]:
2032 // PI ::= '<?' PITarget (S (Char* - (Char* '?>' Char*)))? '?>'
2033 // PITarget ::= Name - (('X'|'x')('M'|'m')('L'|'l'))
2034 // The literal name `xml` (any case) is reserved. After the
2035 // target, the next char MUST be either `?>` or whitespace
2036 // followed by content.
2037 let target = self.scan.scan_name_bytes()?;
2038 if target.eq_ignore_ascii_case(b"xml") {
2039 return Err(self.scan.err(
2040 "PI target name 'xml' is reserved (XML 1.0 § 2.6 [17])"
2041 ));
2042 }
2043 match self.scan.peek() {
2044 Some(b'?') => {
2045 // Immediate close — `<?target?>` with no content.
2046 self.scan.expect_str(b"?>")?;
2047 return Ok(());
2048 }
2049 Some(b' ' | b'\t' | b'\r' | b'\n') => {} // OK, S follows
2050 Some(b) => return Err(self.scan.err(format!(
2051 "expected whitespace or `?>` after PI target, got '{}' (XML 1.0 § 2.6 [16])",
2052 b as char
2053 ))),
2054 None => return Err(self.scan.err("unterminated PI")),
2055 }
2056 loop {
2057 match memchr(b'?', self.scan.cur_tail()) {
2058 None => return Err(self.scan.err("unterminated PI")),
2059 Some(off) => {
2060 self.scan.cur_advance_pos(off);
2061 if self.scan.starts_with(b"?>") { self.scan.skip_n(2); return Ok(()); }
2062 self.scan.advance();
2063 }
2064 }
2065 }
2066 }
2067
2068 fn parse_doctype(&mut self) -> Result<()> {
2069 // Record how many prolog comments/PIs preceded this DOCTYPE so
2070 // the internal-subset node can be spliced into the document
2071 // sibling chain at its true position (see
2072 // `Dtd::internal_subset_prolog_index`).
2073 self.dtd.internal_subset_prolog_index = self.prolog_misc_count;
2074 self.scan.skip_n(9); // "<!DOCTYPE"
2075 self.scan.expect_ws()?;
2076 self.scan.skip_ws();
2077 // Capture the root name so `docinfo.root_name` / the doctype
2078 // serialisation round-trip correctly. Names are pure ASCII
2079 // identifiers — capture the bytes between scan-name's
2080 // before/after offsets.
2081 let name_start = self.scan.src_offset();
2082 self.scan.skip_name()?;
2083 let name_end = self.scan.src_offset();
2084 // SAFETY: scan.skip_name advanced over a valid XML Name in
2085 // the input buffer; those bytes are valid UTF-8.
2086 let root_name = unsafe {
2087 std::str::from_utf8_unchecked(&self.scan.src_bytes()[name_start..name_end])
2088 }.to_string();
2089 self.dtd.root_name = root_name;
2090 self.scan.skip_ws();
2091
2092 // SYSTEM / PUBLIC identifier for the optional external subset.
2093 // We always capture both for `docinfo.public_id` /
2094 // `docinfo.system_url`; the captured system-id additionally
2095 // drives external-subset loading when `load_external_dtd` is
2096 // on.
2097 let mut external_system_id: Option<String> = None;
2098 if self.scan.starts_with(b"SYSTEM") || self.scan.starts_with(b"PUBLIC") {
2099 let is_public = self.scan.starts_with(b"PUBLIC");
2100 self.scan.skip_n(6);
2101 self.scan.expect_ws()?;
2102 self.scan.skip_ws();
2103 if is_public {
2104 let pub_id = self.capture_pubid_literal()?;
2105 self.dtd.public_id = Some(pub_id);
2106 // Per XML 1.0 § 4.2.2 [75]: PUBLIC PubidLiteral
2107 // SystemLiteral — whitespace between the two is
2108 // REQUIRED. We diverge from strict spec only in
2109 // letting the SystemLiteral be omitted entirely
2110 // (the HTML-style `<!DOCTYPE html PUBLIC "...">`
2111 // shape libxml2/lxml accept). When omitted, we
2112 // expect `>` or `[` next; mandatory whitespace is
2113 // still enforced before a SystemLiteral.
2114 let saw_ws = matches!(self.scan.peek(), Some(b' ' | b'\t' | b'\n' | b'\r'));
2115 self.scan.skip_ws();
2116 if matches!(self.scan.peek(), Some(b'"' | b'\'')) {
2117 if !saw_ws {
2118 return Err(self.scan.err(
2119 "whitespace is required between PubidLiteral and SystemLiteral \
2120 (XML 1.0 § 4.2.2 [75] ExternalID)"
2121 ));
2122 }
2123 let sys_id = self.capture_quoted()?;
2124 self.dtd.system_id = Some(sys_id.clone());
2125 if self.scan.opts.load_external_dtd {
2126 external_system_id = Some(sys_id);
2127 }
2128 }
2129 } else {
2130 let sys_id = self.capture_quoted()?;
2131 self.dtd.system_id = Some(sys_id.clone());
2132 // The external DTD subset loads only under
2133 // `load_external_dtd` (libxml2's `XML_PARSE_DTDLOAD`). A
2134 // configured resolver is the *mechanism* for loading it
2135 // (and for general-entity resolution), not the trigger —
2136 // lxml always registers one yet defaults to `load_dtd=False`.
2137 if self.scan.opts.load_external_dtd {
2138 external_system_id = Some(sys_id);
2139 }
2140 }
2141 self.scan.skip_ws();
2142 }
2143
2144 if self.scan.peek() == Some(b'[') {
2145 self.scan.advance();
2146 self.parse_internal_subset()?;
2147 }
2148
2149 self.scan.skip_ws();
2150 self.scan.expect(b'>')?;
2151
2152 // External subset is loaded AFTER the internal subset and
2153 // AFTER the closing `>`. Load failures (file-not-found,
2154 // non-UTF-8, network URI) are silently downgraded to
2155 // warnings inside `load_external_subset` so we still parse
2156 // the document. Parse failures (malformed declarations,
2157 // ill-formed conditional sections, etc.) propagate as real
2158 // well-formedness errors — except in `recovery_mode` where
2159 // we demote them to warnings too. This requires the
2160 // external-subset parser to expand PE references inside
2161 // markup declarations (XML 1.0 § 4.4.8) — otherwise valid
2162 // documents using PE refs in their DTD would all fail.
2163 if let Some(system_id) = external_system_id {
2164 if let Err(e) = self.load_external_subset(&system_id) {
2165 if self.scan.opts.recovery_mode {
2166 self.recovered_errors.push(e);
2167 } else {
2168 return Err(e);
2169 }
2170 }
2171 }
2172
2173 Ok(())
2174 }
2175
2176 /// Attempt to load and parse the external DTD subset.
2177 ///
2178 /// Two error categories:
2179 /// - **Load failures** (resolver refused, file not found,
2180 /// non-UTF-8, network URI) → logged as a warning in
2181 /// `recovered_errors`; returns `Ok(())`. These match
2182 /// libxml2's "load DTD if you can, ignore otherwise"
2183 /// stance when running without strict validation.
2184 /// - **Parse failures** (malformed declarations, ill-formed
2185 /// conditional sections, etc.) → returned as `Err`. These
2186 /// are well-formedness violations the spec requires us to
2187 /// surface; the caller decides whether to propagate or
2188 /// swallow based on `recovery_mode`.
2189 ///
2190 /// Source-of-bytes precedence:
2191 /// 1. `external_resolver`, when configured — the resolver is
2192 /// the unified entry point for external loading; its
2193 /// allowlists / catalog logic apply.
2194 /// 2. Direct `std::fs::read`, gated by `load_external_dtd` —
2195 /// historical fallback for the lean parse path; only fires
2196 /// when no resolver is set.
2197 fn load_external_subset(&mut self, system_id: &str) -> Result<()> {
2198 // The DOCTYPE's external-subset SYSTEM is resolved against
2199 // the *document* URL — there's no enclosing external entity
2200 // at this point, so `current_base_uri()` is irrelevant.
2201 let base = self.scan.opts.base_url.clone();
2202 let absolute = resolve_uri(system_id, base.as_deref());
2203 let bytes_result: std::result::Result<Vec<u8>, String> =
2204 if let Some(resolver) = self.scan.opts.external_resolver.clone() {
2205 resolver.resolve(None, &absolute, base.as_deref())
2206 .map_err(|e| e.to_string())
2207 } else if self.scan.opts.load_external_dtd {
2208 // Network URIs are out of scope for the lean path.
2209 if absolute.starts_with("http://") || absolute.starts_with("https://") {
2210 return Ok(());
2211 }
2212 let raw_path: &str = absolute.strip_prefix("file://").unwrap_or(&absolute);
2213 std::fs::read(std::path::Path::new(raw_path)).map_err(|e| e.to_string())
2214 } else {
2215 return Ok(());
2216 };
2217 let bytes = match bytes_result {
2218 Ok(b) => b,
2219 Err(msg) => {
2220 // Load failure — log as warning, don't fail the parse.
2221 self.recovered_errors.push(
2222 XmlError::new(
2223 ErrorDomain::Dtd,
2224 ErrorLevel::Warning,
2225 format!("external DTD '{system_id}' not loaded: {msg}"),
2226 )
2227 );
2228 return Ok(());
2229 }
2230 };
2231 let text = match crate::encoding::transcode_to_utf8(&bytes)
2232 .map_err(|e| e.message)
2233 .and_then(|c| String::from_utf8(c.into_owned()).map_err(|e| e.to_string()))
2234 {
2235 Ok(s) => s,
2236 Err(msg) => {
2237 self.recovered_errors.push(
2238 XmlError::new(
2239 ErrorDomain::Dtd,
2240 ErrorLevel::Warning,
2241 format!("external DTD '{system_id}' not valid UTF-8: {msg}"),
2242 )
2243 );
2244 return Ok(());
2245 }
2246 };
2247 // Push the file bytes onto the scanner as an entity stream
2248 // named `__external_dtd__` (the name is for cycle-detection;
2249 // we won't recursively load). After the push, the scanner
2250 // is positioned at byte 0 of the file. Then loop through
2251 // declarations until the stream is empty.
2252 self.scan.push_entity_stream(
2253 "__external_dtd__".to_string(),
2254 text,
2255 self.depth,
2256 Some(absolute),
2257 )?;
2258 let parse_result = consume_text_decl_if_present(&mut self.scan, self.is_xml_11)
2259 .and_then(|()| self.parse_external_subset_loop());
2260 // Drain whatever's left on the pushed stream so subsequent
2261 // parsing sees the original source again — required on both
2262 // success and error paths, otherwise a parse error mid-decl
2263 // leaves the scanner pointing at the entity-stream tail.
2264 while !self.scan.on_original_source() {
2265 if !self.scan.try_pop_entity_stream() { break; }
2266 }
2267 parse_result
2268 }
2269
2270 /// Parse a standalone external DTD subset — the markup
2271 /// declarations a `.dtd` file holds, with no surrounding
2272 /// `<!DOCTYPE>` wrapper or document body — capturing them into
2273 /// [`take_dtd`](Self::take_dtd).
2274 ///
2275 /// Unlike the internal subset, the external subset permits
2276 /// conditional sections (`<![INCLUDE[…]]>` / `<![IGNORE[…]]>`) and
2277 /// top-level parameter-entity references (XML 1.0 § 2.8); this
2278 /// drives [`parse_external_subset_loop`](Self::parse_external_subset_loop)
2279 /// directly over `text` so those constructs parse the same way they
2280 /// would when loaded via a SYSTEM identifier. The reader is
2281 /// expected to have been constructed over an empty source —
2282 /// `text` is pushed as the sole entity-stream frame.
2283 pub(crate) fn parse_standalone_external_subset(&mut self, text: String) -> Result<()> {
2284 self.scan.push_entity_stream(
2285 "__external_dtd__".to_string(),
2286 text,
2287 self.depth,
2288 None,
2289 )?;
2290 let result = consume_text_decl_if_present(&mut self.scan, self.is_xml_11)
2291 .and_then(|()| self.parse_external_subset_loop());
2292 while !self.scan.on_original_source() {
2293 if !self.scan.try_pop_entity_stream() { break; }
2294 }
2295 result
2296 }
2297
2298 /// Declaration-collection loop for the external DTD subset.
2299 /// Reads `<!ELEMENT>`, `<!ATTLIST>`, `<!ENTITY>`, `<!NOTATION>`,
2300 /// comments, PIs, and conditional `<![INCLUDE[...]]>` /
2301 /// `<![IGNORE[...]]>` sections until the pushed stream is
2302 /// exhausted. Errors return the first issue but don't abort
2303 /// the outer parse (the caller logs and continues).
2304 fn parse_external_subset_loop(&mut self) -> Result<()> {
2305 loop {
2306 // The external subset is bounded by the entity-stream
2307 // frame that `load_external_subset` pushed; once the
2308 // scanner is back on the original source, we've drained
2309 // it (either we ran out of bytes inside the frame and
2310 // popped, or a PE expansion ended at the same frame
2311 // boundary). Returning here is critical: without it,
2312 // the loop would happily continue reading the document
2313 // body's `<doc>` as if it were a DTD declaration.
2314 if self.scan.on_original_source() {
2315 return Ok(());
2316 }
2317 self.scan.skip_ws();
2318 if self.scan.peek().is_none() {
2319 // Top-of-stream empty — pop the frame and re-check
2320 // (either we exit via the on_original_source guard
2321 // above on the next iteration, or we land in a
2322 // deeper PE frame and keep going).
2323 if !self.scan.try_pop_entity_stream() {
2324 return Ok(());
2325 }
2326 continue;
2327 }
2328 match self.scan.peek() {
2329 Some(b'<') => {
2330 // XML 1.0 § 2.8 WFC: PE Between Declarations —
2331 // a markup declaration's `<!` and `>` must come
2332 // from the same entity frame. Record the frame
2333 // depth at start, verify at end. This catches
2334 // declarations split across PE boundaries like:
2335 // <!ENTITY % m "<!ELEMENT x ">
2336 // %m;ANY>
2337 // where `<!ELEMENT x ` lives in m's expansion
2338 // and the closing `>` in the outer source.
2339 let start_depth = self.scan.entity_stream_depth();
2340 if self.scan.starts_with(b"<!--") { self.skip_comment_raw()?; }
2341 else if self.scan.starts_with(b"<!ENTITY") { self.parse_entity_decl()?; }
2342 else if self.scan.starts_with(b"<!ATTLIST") { self.parse_attlist_decl()?; }
2343 else if self.scan.starts_with(b"<!ELEMENT") { self.parse_element_decl()?; }
2344 else if self.scan.starts_with(b"<!NOTATION") { self.parse_notation_decl()?; }
2345 else if self.scan.starts_with(b"<?") { self.skip_pi_raw()?; }
2346 else if self.scan.starts_with(b"<![") {
2347 self.parse_conditional_section()?;
2348 }
2349 else {
2350 return Err(self.scan.err(
2351 "unexpected declaration in external DTD subset"
2352 ));
2353 }
2354 if self.scan.entity_stream_depth() < start_depth {
2355 return Err(self.scan.err(
2356 "markup declaration is split across a parameter-entity \
2357 boundary (XML 1.0 § 2.8 WFC: PE Between Declarations) — \
2358 the start `<!` and end `>` of a declaration must come \
2359 from the same entity"
2360 ));
2361 }
2362 }
2363 Some(b'%') => {
2364 // Parameter-entity reference at the top level
2365 // between declarations. Per XML 1.0 § 4.4.8
2366 // "Included", expand the PE so the next loop
2367 // iteration sees its replacement text.
2368 self.expand_pe_ref_at_cursor()?;
2369 }
2370 _ => return Err(self.scan.err("unexpected content in external DTD subset")),
2371 }
2372 }
2373 }
2374
2375 /// Expand the parameter-entity reference at the current scanner
2376 /// position. Called when `peek() == Some('%')` in a context
2377 /// where PE references are allowed (the external DTD subset,
2378 /// PE-replacement text). Consumes `%name;` from the input,
2379 /// looks `name` up in [`parameter_entities`], and pushes the
2380 /// replacement text onto the scanner as a new entity stream
2381 /// — surrounded by spaces per § 4.4.8 "Included" so the PE
2382 /// can never silently merge adjacent tokens.
2383 ///
2384 /// Undefined PEs return an error (WFC: Entity Declared).
2385 /// External PEs whose resolver never loaded the bytes are
2386 /// silently skipped (no replacement text to inject).
2387 fn expand_pe_ref_at_cursor(&mut self) -> Result<()> {
2388 self.scan.expect(b'%')?;
2389 let name_bytes = self.scan.scan_name_bytes()?;
2390 self.scan.expect(b';')?;
2391 let name = unsafe { std::str::from_utf8_unchecked(&name_bytes) }.to_string();
2392 let kind = match self.parameter_entities.get(&name) {
2393 Some(d) => d.clone(),
2394 None => {
2395 // XML 1.0 § 4.1 WFC: Entity Declared has a carve-out:
2396 // refs that "do not occur within the external subset
2397 // or a parameter entity" are subject to WFC; refs
2398 // *inside* an external entity's replacement text
2399 // (i.e. `current_base_uri().is_some()`) are not — at
2400 // most a VC violation, which non-validating parsers
2401 // MUST tolerate. Log a recoverable warning and
2402 // expand to empty (the entity might be declared
2403 // somewhere we haven't read yet).
2404 if self.scan.current_base_uri().is_some() {
2405 self.recovered_errors.push(XmlError::new(
2406 ErrorDomain::Parser,
2407 ErrorLevel::Warning,
2408 format!(
2409 "undefined parameter entity '%{name};' inside an external \
2410 entity — WFC: Entity Declared carve-out applies (XML 1.0 § 4.1); \
2411 expansion skipped"
2412 ),
2413 ));
2414 return Ok(());
2415 }
2416 return Err(self.scan.err(format!(
2417 "undefined parameter entity '%{name};' (XML 1.0 § 4.1 WFC: Entity Declared)"
2418 )));
2419 }
2420 };
2421 self.pe_ref_in_internal_subset_seen = true;
2422 let is_external_value = kind.kind.is_external_value();
2423 let value = match kind.kind {
2424 EntityKind::InternalText(v) | EntityKind::ExternalLoaded(v) => v,
2425 EntityKind::ExternalUnloaded => return Ok(()),
2426 };
2427 // §4.4.8 "Included": the replacement text MUST be padded
2428 // with one leading and one trailing space so the PE can't
2429 // smudge adjacent tokens in the including context.
2430 let padded = format!(" {value} ");
2431 let depth = self.element_stack.len() as u32;
2432 // Propagate the entity's source URL into the new stream
2433 // frame so nested SYSTEM identifiers can be resolved
2434 // relative to where these bytes came from (XML 1.0 § 4.2.2
2435 // + errata E18). `None` for internal PEs.
2436 let frame_base = kind.source_uri.clone();
2437 self.scan.push_entity_stream(name, padded, depth, frame_base)?;
2438 if is_external_value {
2439 consume_text_decl_if_present(&mut self.scan, self.is_xml_11)?;
2440 }
2441 Ok(())
2442 }
2443
2444 /// Like [`Scanner::skip_ws`] but, in a context where PE
2445 /// references are allowed (the external DTD subset and
2446 /// PE-replacement text), also expands any `%name;` it
2447 /// encounters between whitespace runs. Required by markup
2448 /// declaration parsers so their `skip_ws` between tokens
2449 /// doesn't trip over PE references the spec lets land there.
2450 fn skip_ws_and_pe_refs(&mut self) -> Result<()> {
2451 loop {
2452 self.scan.skip_ws();
2453 if self.scan.peek().is_none() {
2454 // Current stream exhausted. If it's a PE-replacement
2455 // frame, pop and continue against the parent so the
2456 // caller's `expect('>')` etc. sees the bytes that
2457 // lived past the PE reference in the outer source.
2458 // Without this, e.g.
2459 // <!ELEMENT x %ct;>
2460 // would EOF after consuming `%ct;`'s replacement
2461 // text and never reach the trailing `>`.
2462 if self.scan.on_original_source() { return Ok(()); }
2463 if !self.scan.try_pop_entity_stream() { return Ok(()); }
2464 continue;
2465 }
2466 if self.scan.peek() != Some(b'%') { return Ok(()); }
2467 // PE references aren't allowed inside markup declarations
2468 // in the *internal* subset (XML 1.0 § 2.8 WFC: PEs in
2469 // Internal Subset). Only expand when we're on
2470 // PE-replaced or external-subset bytes.
2471 if self.scan.on_original_source() { return Ok(()); }
2472 self.expand_pe_ref_at_cursor()?;
2473 }
2474 }
2475
2476 /// `expect_ws` for DTD contexts where a PE reference may stand
2477 /// in for required whitespace (XML 1.0 § 4.4.8 "Included":
2478 /// PE replacement text is space-padded, so an expansion at a
2479 /// whitespace-required boundary contributes its leading space).
2480 /// Either consumes one or more whitespace bytes OR expands a
2481 /// PE first and consumes its leading-space pad; on neither,
2482 /// errors with the underlying `expect_ws` diagnostic.
2483 fn expect_ws_with_pe(&mut self) -> Result<()> {
2484 if self.scan.peek() == Some(b'%') && !self.scan.on_original_source() {
2485 self.expand_pe_ref_at_cursor()?;
2486 }
2487 self.scan.expect_ws()?;
2488 self.skip_ws_and_pe_refs()
2489 }
2490
2491 /// Compute the replacement text of an internal entity per
2492 /// XML 1.0 § 4.5. The input `bytes` is the raw EntityValue
2493 /// literal (everything between the surrounding quotes).
2494 /// The output is the literal with:
2495 ///
2496 /// * Character references (`&#…;`) decoded to their UTF-8
2497 /// bytes.
2498 /// * Parameter-entity references (`%name;`) replaced by the
2499 /// referenced entity's already-computed replacement text
2500 /// ("Included in Literal", § 4.4.5 — no space padding,
2501 /// unlike "Included" which applies in markup-decl context).
2502 /// * General-entity references (`&name;`) left LITERAL —
2503 /// they're "Bypassed" per § 4.4.7 and expand only at
2504 /// eventual reference time.
2505 ///
2506 /// XML 1.0 § 2.8 WFC "PEs in Internal Subset" forbids `%`
2507 /// references inside markup declarations of the internal
2508 /// subset; the caller has already enforced this at byte-scan
2509 /// time, so any `%` reaching here came from external-subset
2510 /// or PE-replacement text and is legal to expand.
2511 fn expand_entity_value(&self, bytes: &[u8]) -> std::result::Result<Vec<u8>, String> {
2512 let mut out = Vec::with_capacity(bytes.len());
2513 let mut i = 0;
2514 while i < bytes.len() {
2515 let b = bytes[i];
2516 match b {
2517 b'&' => {
2518 let after = i + 1;
2519 if after >= bytes.len() {
2520 return Err("entity value ends with `&`".to_string());
2521 }
2522 if bytes[after] != b'#' {
2523 // General entity reference — bypass per § 4.4.7.
2524 let semi = bytes[after..].iter().position(|&c| c == b';')
2525 .ok_or_else(|| "named entity reference in entity value missing `;`".to_string())?;
2526 out.extend_from_slice(&bytes[i..after + semi + 1]);
2527 i = after + semi + 1;
2528 } else {
2529 // Character reference — decode.
2530 let body_start = after + 1;
2531 let semi = bytes[body_start..].iter().position(|&c| c == b';')
2532 .ok_or_else(|| "character reference missing `;`".to_string())?;
2533 let body = &bytes[body_start..body_start + semi];
2534 let cp: u32 = if body.first() == Some(&b'x') || body.first() == Some(&b'X') {
2535 std::str::from_utf8(&body[1..]).ok()
2536 .and_then(|h| u32::from_str_radix(h, 16).ok())
2537 .ok_or_else(|| format!(
2538 "invalid hex character reference '&#{}'",
2539 String::from_utf8_lossy(body)
2540 ))?
2541 } else {
2542 std::str::from_utf8(body).ok()
2543 .and_then(|d| d.parse::<u32>().ok())
2544 .ok_or_else(|| format!(
2545 "invalid decimal character reference '&#{}'",
2546 String::from_utf8_lossy(body)
2547 ))?
2548 };
2549 let ch = char::from_u32(cp).ok_or_else(|| format!(
2550 "character reference '&#{};' is not a valid Unicode scalar", cp
2551 ))?;
2552 let mut tmp = [0u8; 4];
2553 out.extend_from_slice(ch.encode_utf8(&mut tmp).as_bytes());
2554 i = body_start + semi + 1;
2555 }
2556 }
2557 b'%' => {
2558 // Parameter-entity reference. Look up and
2559 // splice replacement text inline (§ 4.4.5
2560 // "Included in Literal" — no space padding).
2561 let semi = bytes[i + 1..].iter().position(|&c| c == b';')
2562 .ok_or_else(|| "PE reference in entity value missing `;`".to_string())?;
2563 let name_bytes = &bytes[i + 1..i + 1 + semi];
2564 let name = std::str::from_utf8(name_bytes)
2565 .map_err(|e| format!("PE name not valid UTF-8: {e}"))?;
2566 match self.parameter_entities.get(name).map(|d| &d.kind) {
2567 Some(EntityKind::InternalText(v))
2568 | Some(EntityKind::ExternalLoaded(v)) => {
2569 out.extend_from_slice(v.as_bytes());
2570 }
2571 Some(EntityKind::ExternalUnloaded) => {
2572 // No replacement text to splice — skip
2573 // silently, matching how reference-time
2574 // expansion handles unloaded externals.
2575 }
2576 None => {
2577 // XML 1.0 § 4.1 WFC: Entity Declared
2578 // carve-out for refs inside an external
2579 // entity — non-validating parsers MUST
2580 // tolerate. Splice nothing and move on.
2581 if self.scan.current_base_uri().is_some() {
2582 // skip silently
2583 } else {
2584 return Err(format!(
2585 "undefined parameter entity '%{name};' \
2586 (XML 1.0 § 4.1 WFC: Entity Declared)"
2587 ));
2588 }
2589 }
2590 }
2591 i = i + 1 + semi + 1;
2592 }
2593 _ => {
2594 out.push(b);
2595 i += 1;
2596 }
2597 }
2598 }
2599 Ok(out)
2600 }
2601
2602 /// Parse a `<![ … ]]>` conditional section per XML 1.0 § 3.4
2603 /// [62-65]. Validates that the keyword is `INCLUDE` or
2604 /// `IGNORE`, that a `[` follows, that the section terminates
2605 /// with `]]>`, and that nested conditional sections are
2606 /// balanced. The body contents themselves are not deeply
2607 /// validated for now — once the opening / closing tokens
2608 /// check out we skip the body with nesting-aware scanning.
2609 fn parse_conditional_section(&mut self) -> Result<()> {
2610 self.scan.skip_n(3); // consume `<![`
2611 // The keyword (INCLUDE / IGNORE) is commonly supplied via a
2612 // PE in real DTDs — `<![ %active; [ … ]]>`. Expand any PE
2613 // here so the next token is the actual keyword.
2614 self.skip_ws_and_pe_refs()?;
2615 let kw_start = self.scan.cur_pos();
2616 if self.scan.skip_name().is_err() {
2617 return Err(self.scan.err(
2618 "conditional section needs a keyword after '<![' \
2619 (XML 1.0 § 3.4 [62])"
2620 ));
2621 }
2622 let kw_end = self.scan.cur_pos();
2623 let kw = self.scan.cur_slice(kw_start, kw_end).to_vec();
2624 let is_include = match kw.as_slice() {
2625 b"INCLUDE" => true,
2626 b"IGNORE" => false,
2627 other => return Err(self.scan.err(format!(
2628 "conditional section keyword must be INCLUDE or IGNORE, got {:?} \
2629 (XML 1.0 § 3.4 [62])",
2630 String::from_utf8_lossy(other),
2631 ))),
2632 };
2633 self.skip_ws_and_pe_refs()?;
2634 if !self.scan.starts_with(b"[") {
2635 return Err(self.scan.err(
2636 "expected '[' after INCLUDE/IGNORE in conditional section \
2637 (XML 1.0 § 3.4 [62])"
2638 ));
2639 }
2640 self.scan.skip_n(1);
2641 if is_include {
2642 self.parse_include_section_body()
2643 } else {
2644 self.skip_ignore_section_body()
2645 }
2646 }
2647
2648 /// INCLUDE-section body: process declarations until `]]>`.
2649 /// Mirrors [`parse_external_subset_loop`]'s decl dispatch but
2650 /// terminates on the conditional-section close-delimiter
2651 /// rather than on stream exhaustion / original-source return.
2652 fn parse_include_section_body(&mut self) -> Result<()> {
2653 loop {
2654 self.scan.skip_ws();
2655 if self.scan.starts_with(b"]]>") {
2656 self.scan.skip_n(3);
2657 return Ok(());
2658 }
2659 // Pop empty PE-replacement frames so the `]]>` in the
2660 // parent surfaces (PE expansion inside the INCLUDE body
2661 // is common: `<![INCLUDE[ %decls; ]]>`).
2662 if self.scan.peek().is_none() {
2663 if self.scan.on_original_source() {
2664 return Err(self.scan.err(
2665 "unterminated INCLUDE conditional section (expected ']]>')"
2666 ));
2667 }
2668 if !self.scan.try_pop_entity_stream() {
2669 return Err(self.scan.err(
2670 "unterminated INCLUDE conditional section (expected ']]>')"
2671 ));
2672 }
2673 continue;
2674 }
2675 match self.scan.peek() {
2676 Some(b'<') => {
2677 if self.scan.starts_with(b"<!--") { self.skip_comment_raw()?; }
2678 else if self.scan.starts_with(b"<!ENTITY") { self.parse_entity_decl()?; }
2679 else if self.scan.starts_with(b"<!ATTLIST") { self.parse_attlist_decl()?; }
2680 else if self.scan.starts_with(b"<!ELEMENT") { self.parse_element_decl()?; }
2681 else if self.scan.starts_with(b"<!NOTATION") { self.parse_notation_decl()?; }
2682 else if self.scan.starts_with(b"<?") { self.skip_pi_raw()?; }
2683 else if self.scan.starts_with(b"<![") { self.parse_conditional_section()?; }
2684 else {
2685 return Err(self.scan.err(
2686 "unexpected declaration in INCLUDE conditional section"
2687 ));
2688 }
2689 }
2690 Some(b'%') => self.expand_pe_ref_at_cursor()?,
2691 _ => return Err(self.scan.err(
2692 "unexpected content in INCLUDE conditional section"
2693 )),
2694 }
2695 }
2696 }
2697
2698 /// IGNORE-section body: discard everything up to the matching
2699 /// `]]>`, respecting nesting via `<![ … ]]>` pairs. Entity
2700 /// streams pop transparently so the terminator can live in the
2701 /// outer source when the body was supplied via a PE expansion.
2702 fn skip_ignore_section_body(&mut self) -> Result<()> {
2703 let mut depth = 1usize;
2704 loop {
2705 let tail = self.scan.cur_tail();
2706 if tail.is_empty() {
2707 if self.scan.on_original_source() {
2708 return Err(self.scan.err(
2709 "unterminated IGNORE conditional section (expected ']]>')"
2710 ));
2711 }
2712 if !self.scan.try_pop_entity_stream() {
2713 return Err(self.scan.err(
2714 "unterminated IGNORE conditional section (expected ']]>')"
2715 ));
2716 }
2717 continue;
2718 }
2719 if tail.starts_with(b"<![") {
2720 depth += 1;
2721 self.scan.skip_n(3);
2722 } else if tail.starts_with(b"]]>") {
2723 self.scan.skip_n(3);
2724 depth -= 1;
2725 if depth == 0 { return Ok(()); }
2726 } else {
2727 self.scan.advance();
2728 }
2729 }
2730 }
2731
2732 fn skip_pubid_literal(&mut self) -> Result<()> {
2733 // capture-and-discard form; we keep the parsing logic in
2734 // capture_pubid_literal below and just throw the result away.
2735 self.capture_pubid_literal().map(|_| ())
2736 }
2737
2738 /// Same as [`skip_pubid_literal`] but returns the body bytes.
2739 /// Used by `parse_doctype` to preserve the PUBLIC literal so
2740 /// consumers reading `docinfo.public_id` get the original string.
2741 fn capture_pubid_literal(&mut self) -> Result<String> {
2742 let q = match self.scan.advance() {
2743 Some(b @ (b'"' | b'\'')) => b,
2744 _ => return Err(self.scan.err("expected PubidLiteral")),
2745 };
2746 let tail = self.scan.cur_tail();
2747 let off = memchr(q, tail).ok_or_else(|| self.scan.err("unterminated PubidLiteral"))?;
2748 let body = &tail[..off];
2749 for &b in body {
2750 if !is_pubid_char(b) {
2751 return Err(self.scan.err(format!("invalid PubidChar 0x{b:02X}")));
2752 }
2753 }
2754 // SAFETY: every byte just validated as ASCII (pubid chars
2755 // are a subset of ASCII), so the slice is valid UTF-8.
2756 let out = unsafe { std::str::from_utf8_unchecked(body) }.to_string();
2757 self.scan.cur_advance_pos(off + 1);
2758 Ok(out)
2759 }
2760
2761 /// Record the raw source text of the internal-subset declaration
2762 /// just parsed (`decl_start` .. current position) into the DTD's
2763 /// ordered `internal_decls`. Skipped unless the scanner read the
2764 /// whole declaration directly from the original source (a
2765 /// parameter-entity expansion has no stable source offset).
2766 fn capture_internal_decl(&mut self, decl_on_src: bool, decl_start: usize) {
2767 if !decl_on_src || !self.scan.on_original_source() {
2768 return;
2769 }
2770 let end = self.scan.cur_pos();
2771 if end > decl_start {
2772 let text = self.scan.original_slice(decl_start, end);
2773 if !text.is_empty() {
2774 self.dtd.internal_decls.push(text.to_string());
2775 }
2776 }
2777 }
2778
2779 fn parse_internal_subset(&mut self) -> Result<()> {
2780 loop {
2781 self.scan.skip_ws();
2782 // If we're parsing inside a PE-replacement stream and
2783 // it's exhausted, pop back to the parent stream and
2784 // continue. Spec (§ 4.4.8) requires PE expansions to
2785 // contain a sequence of complete declarations, so the
2786 // pop point should land us between declarations cleanly.
2787 while self.scan.peek().is_none() && !self.scan.on_original_source() {
2788 if !self.scan.try_pop_entity_stream() { break; }
2789 self.scan.skip_ws();
2790 }
2791 match self.scan.peek() {
2792 None => return Err(self.scan.err("unterminated DOCTYPE internal subset")),
2793 Some(b']') => { self.scan.advance(); return Ok(()); }
2794 Some(b'<') => {
2795 // Capture each declaration's raw source span (in
2796 // document order) for round-trip serialization of
2797 // the internal subset. Only declarations read
2798 // directly from the source are captured.
2799 let decl_on_src = self.scan.on_original_source();
2800 let decl_start = self.scan.cur_pos();
2801 if self.scan.starts_with(b"<!--") { self.skip_comment_raw()?; }
2802 else if self.scan.starts_with(b"<!ENTITY") { self.parse_entity_decl()?; self.capture_internal_decl(decl_on_src, decl_start); }
2803 else if self.scan.starts_with(b"<!ATTLIST") { self.parse_attlist_decl()?; self.capture_internal_decl(decl_on_src, decl_start); }
2804 else if self.scan.starts_with(b"<!ELEMENT") { self.parse_element_decl()?; self.capture_internal_decl(decl_on_src, decl_start); }
2805 else if self.scan.starts_with(b"<!NOTATION"){ self.parse_notation_decl()?; self.capture_internal_decl(decl_on_src, decl_start); }
2806 else if self.scan.starts_with(b"<![") {
2807 // XML 1.0 § 3.4 [62] [conditionalSect]: only legal
2808 // in the EXTERNAL subset. Errata-2e clarification:
2809 // when a parameter-entity reference inside the
2810 // internal subset expands to markup containing a
2811 // conditional section, that markup is processed
2812 // as if external — so conditional sections ARE
2813 // valid there. We distinguish by whether the
2814 // scanner is currently reading from the original
2815 // source bytes (true internal subset, forbidden)
2816 // or from an entity-replacement stream (PE
2817 // expansion, allowed).
2818 if self.scan.on_original_source() {
2819 return Err(self.scan.err(
2820 "conditional sections are only allowed in the external DTD subset \
2821 (XML 1.0 § 3.4 [62])"
2822 ));
2823 }
2824 self.parse_conditional_section()?;
2825 }
2826 else if self.scan.starts_with(b"<?") { self.skip_pi_raw()?; }
2827 else {
2828 return Err(self.scan.err(
2829 "unexpected declaration in DOCTYPE internal subset"
2830 ));
2831 }
2832 }
2833 Some(b'%') => {
2834 // XML 1.0 § 4.4.8 [Included as PE]: a parameter-
2835 // entity reference inside the internal subset
2836 // expands to its replacement text. We push the
2837 // text as an entity stream so the next loop
2838 // iteration parses against it — the existing
2839 // declaration / PI / comment parsers naturally
2840 // catch violations like "PE expanded to an XML
2841 // declaration in the wrong place" (the PI parser
2842 // sees target `xml` and rejects).
2843 self.scan.advance();
2844 let name_bytes = self.scan.scan_name_bytes()?;
2845 self.scan.expect(b';')?;
2846 let name_str = unsafe {
2847 std::str::from_utf8_unchecked(&name_bytes)
2848 };
2849 // XML 1.0 errata E13: noting that a PE reference
2850 // appeared inside the internal subset relaxes
2851 // undeclared-general-entity errors later (those
2852 // become validity errors, not WF errors, since
2853 // the PE expansion could in principle declare
2854 // them). Set the flag here whether or not the
2855 // PE itself is declared — the rule fires on the
2856 // mere appearance of the reference.
2857 self.pe_ref_in_internal_subset_seen = true;
2858 let decl = match self.parameter_entities.get(name_str) {
2859 Some(d) => d.clone(),
2860 None => {
2861 // WFC: Entity Declared carve-out — refs
2862 // *inside* external entity content are
2863 // exempt from the WF rule. Non-validating
2864 // parser MUST tolerate; log a recoverable
2865 // warning and skip the expansion.
2866 if self.scan.current_base_uri().is_some() {
2867 self.recovered_errors.push(XmlError::new(
2868 ErrorDomain::Parser,
2869 ErrorLevel::Warning,
2870 format!(
2871 "undefined parameter entity '%{name_str};' \
2872 inside an external entity — WFC: Entity \
2873 Declared carve-out applies (XML 1.0 § 4.1); \
2874 expansion skipped"
2875 ),
2876 ));
2877 continue;
2878 }
2879 return Err(self.scan.err(format!(
2880 "undefined parameter entity '%{name_str};' \
2881 (XML 1.0 § 4.1 WFC: Entity Declared)"
2882 )));
2883 }
2884 };
2885 let is_external = decl.kind.is_external_value();
2886 let frame_base = decl.source_uri.clone();
2887 let value = match decl.kind {
2888 EntityKind::InternalText(v) | EntityKind::ExternalLoaded(v) => v,
2889 EntityKind::ExternalUnloaded => {
2890 // Declared external but the resolver
2891 // didn't load it. Skip — no replacement
2892 // text means no expansion. Per XML 1.0
2893 // §4.4.3, a non-validating parser MAY
2894 // include but isn't required to.
2895 continue;
2896 }
2897 };
2898 let depth = self.element_stack.len() as u32;
2899 self.scan.push_entity_stream(name_str.to_string(), value, depth, frame_base)?;
2900 if is_external {
2901 // XML 1.0 §4.3.1: only *external* parsed
2902 // entities may begin with a text declaration.
2903 // Internal PE content with `<?xml ...?>` at
2904 // the start is not-wf and must surface as a
2905 // reserved-PI error, not be swallowed.
2906 consume_text_decl_if_present(&mut self.scan, self.is_xml_11)?;
2907 }
2908 }
2909 _ => return Err(self.scan.err("unexpected content in DOCTYPE internal subset")),
2910 }
2911 }
2912 }
2913
2914 fn parse_entity_decl(&mut self) -> Result<()> {
2915 // Capture decl origin BEFORE consuming the keyword:
2916 // anything not on the original source bytes is, by spec,
2917 // "external" for WFC purposes (the external subset itself
2918 // OR a parameter-entity's replacement text).
2919 let declared_external = !self.scan.on_original_source();
2920 self.scan.skip_n(8); // "<!ENTITY"
2921 self.scan.expect_ws()?;
2922 self.scan.skip_ws();
2923
2924 let is_param = self.scan.peek() == Some(b'%');
2925 if is_param { self.scan.advance(); self.scan.expect_ws()?; self.scan.skip_ws(); }
2926
2927 // The entity HashMap is keyed by `String` — same shape as in
2928 // XmlReader, since entity values come from DTD parsing which
2929 // produces text content. Convert the byte name we just scanned
2930 // via from_utf8_unchecked (Scanner invariant: bytes are UTF-8).
2931 let name_bytes = self.scan.scan_name_bytes()?;
2932 // XML Namespaces 1.0 § 3 forbids colons in entity names —
2933 // they're not addressable via the namespace-prefix
2934 // machinery and would let a doc smuggle a name that looks
2935 // like a QName. Gated on `namespace_aware`.
2936 if self.scan.opts.namespace_aware && name_bytes.contains(&b':') {
2937 return Err(self.scan.err(format!(
2938 "entity name '{}' must be an NCName (no colon) under \
2939 XML Namespaces 1.0",
2940 String::from_utf8_lossy(&name_bytes)
2941 )));
2942 }
2943 let name = unsafe { std::str::from_utf8_unchecked(&name_bytes) }.to_string();
2944 self.scan.expect_ws()?;
2945 self.scan.skip_ws();
2946
2947 // External entity: `SYSTEM SystemLiteral` or `PUBLIC PubidLiteral SystemLiteral`.
2948 // SystemLiteral must be quoted; PubidLiteral content must be
2949 // PubidChar. When an `external_resolver` is configured we
2950 // capture the IDs and ask the resolver for the bytes; the
2951 // resolved replacement text gets inserted into the entity
2952 // map just like an internal entity would, so subsequent
2953 // `&name;` references expand normally.
2954 let mut is_external = false;
2955 let mut external_public_id: Option<String> = None;
2956 let mut external_system_id: Option<String> = None;
2957 // Captured for the DTD object model (lxml's `DTD.entities()`) and
2958 // the DTD serializer, pushed once at the end of the declaration.
2959 let model_name = name.clone();
2960 let mut ent_orig: Option<String> = None;
2961 let mut ent_content: Option<String> = None;
2962 let mut ent_ndata: Option<String> = None;
2963 if self.scan.starts_with(b"SYSTEM") {
2964 is_external = true;
2965 self.scan.skip_n(6);
2966 self.scan.expect_ws()?;
2967 self.scan.skip_ws();
2968 if !matches!(self.scan.peek(), Some(b'"' | b'\'')) {
2969 return Err(self.scan.err(
2970 "SYSTEM identifier must be a quoted SystemLiteral (XML 1.0 § 4.2.2 [11])"
2971 ));
2972 }
2973 external_system_id = Some(self.read_system_literal()?);
2974 } else if self.scan.starts_with(b"PUBLIC") {
2975 is_external = true;
2976 self.scan.skip_n(6);
2977 self.scan.expect_ws()?;
2978 self.scan.skip_ws();
2979 external_public_id = Some(self.read_pubid_literal()?);
2980 self.scan.expect_ws()?;
2981 self.scan.skip_ws();
2982 if !matches!(self.scan.peek(), Some(b'"' | b'\'')) {
2983 return Err(self.scan.err(
2984 "PUBLIC requires a SystemLiteral after the PubidLiteral (XML 1.0 § 4.2.2 [75])"
2985 ));
2986 }
2987 external_system_id = Some(self.read_system_literal()?);
2988 } else {
2989 // Internal entity: quoted EntityValue. XML 1.0 § 2.3 [9]
2990 // EntityValue ::= '"' ([^%&"] | PEReference | Reference)* '"'
2991 // | "'" ([^%&'] | PEReference | Reference)* "'"
2992 // Bare `&` (not a valid reference) and bare `%` are forbidden.
2993 let q = match self.scan.peek() {
2994 Some(b @ (b'"' | b'\'')) => { self.scan.advance(); b }
2995 _ => return Err(self.scan.err("expected quoted entity value")),
2996 };
2997 let val_start = self.scan.cur_pos();
2998 while !self.scan.is_eof() && self.scan.peek() != Some(q) {
2999 let b = self.scan.peek().unwrap();
3000 // XML 1.0 § 2.8 WFC "PEs in Internal Subset":
3001 // In the internal DTD subset, parameter-entity
3002 // references MUST NOT occur within markup
3003 // declarations.
3004 // The entity value being parsed here is exactly
3005 // such a markup declaration's content, so a `%`
3006 // (parameter-entity reference start) is forbidden.
3007 if b == b'%' && self.scan.on_original_source() {
3008 return Err(self.scan.err(
3009 "parameter-entity reference '%…;' inside an entity value \
3010 is forbidden in the internal DTD subset \
3011 (XML 1.0 § 2.8 WFC: PEs in Internal Subset)"
3012 ));
3013 }
3014 if b == b'&' || b == b'%' {
3015 // Must be followed by valid Reference / PEReference.
3016 self.scan.advance();
3017 if self.scan.peek() == Some(b'#') && b == b'&' {
3018 self.scan.advance();
3019 if self.scan.peek() == Some(b'x') || self.scan.peek() == Some(b'X') {
3020 self.scan.advance();
3021 while matches!(self.scan.peek(),
3022 Some(c) if (c as char).is_ascii_hexdigit())
3023 {
3024 self.scan.advance();
3025 }
3026 } else {
3027 while matches!(self.scan.peek(),
3028 Some(c) if (c as char).is_ascii_digit())
3029 {
3030 self.scan.advance();
3031 }
3032 }
3033 if self.scan.peek() != Some(b';') {
3034 return Err(self.scan.err(
3035 "invalid character reference in entity value (missing ';')"
3036 ));
3037 }
3038 self.scan.advance();
3039 } else {
3040 // Named reference / PE reference.
3041 self.scan.skip_name()?;
3042 if self.scan.peek() != Some(b';') {
3043 return Err(self.scan.err(format!(
3044 "bare '{}' in entity value — must be a valid reference (XML 1.0 § 2.3 [9])",
3045 b as char
3046 )));
3047 }
3048 self.scan.advance();
3049 }
3050 } else {
3051 self.scan.advance();
3052 }
3053 }
3054 let value_bytes = self.scan.cur_slice(val_start, self.scan.cur_pos());
3055 self.scan.expect(q)?;
3056 // Per § 4.5 "Construction of Internal Entity Replacement
3057 // Text", the replacement text is the literal value
3058 // after expansion of character references AND
3059 // parameter-entity references. General-entity
3060 // references stay literal (they get expanded only at
3061 // reference time, in the eventual including context).
3062 //
3063 // The PE refs are "Included in Literal" (§ 4.4.5) — no
3064 // space padding here, unlike the "Included" rule that
3065 // applies when a PE expands within markup declarations.
3066 let replacement = self.expand_entity_value(&value_bytes)
3067 .map_err(|msg| self.scan.err(msg))?;
3068 // SAFETY: replacement bytes come from valid UTF-8 input
3069 // bytes plus char-ref decoding (which always emits valid
3070 // UTF-8 via `char::encode_utf8`), so the result is also
3071 // valid UTF-8.
3072 // Why unsafe: avoids a redundant `from_utf8` validation
3073 // pass on a buffer we already know is valid.
3074 let value = unsafe { String::from_utf8_unchecked(replacement) };
3075 ent_orig = Some(String::from_utf8_lossy(&value_bytes).into_owned());
3076 ent_content = Some(value.clone());
3077 // Internal entity: store in the right map by kind.
3078 // General entities go into `entities` (referenced as
3079 // `&name;`); parameter entities into `parameter_entities`
3080 // (referenced as `%name;` only inside the DTD).
3081 //
3082 // XML 1.0 § 4.2: "If the same entity is declared more than
3083 // once, the first declaration encountered is binding."
3084 // Use `entry().or_insert` so a second decl of the same
3085 // name is silently ignored — matches valid-sa-086 and the
3086 // libxml2 behaviour.
3087 let decl = EntityDecl {
3088 kind: EntityKind::InternalText(value),
3089 declared_external,
3090 source_uri: None,
3091 };
3092 if is_param {
3093 self.parameter_entities.entry(name.clone()).or_insert(decl);
3094 } else {
3095 self.entities.entry(name.clone()).or_insert(decl);
3096 }
3097 }
3098 // Optional NDATA annotation on external general entities
3099 // (forbidden on parameter entities — § 4.2 [74]).
3100 // XML 1.0 § 4.2.2 [76]: NDataDecl ::= S 'NDATA' S Name —
3101 // whitespace is REQUIRED before `NDATA`, not optional.
3102 let saw_ws_before_ndata = {
3103 let before = self.scan.cur_pos();
3104 self.scan.skip_ws();
3105 self.scan.cur_pos() != before
3106 };
3107 let mut is_unparsed = false;
3108 if self.scan.starts_with(b"NDATA") {
3109 if !saw_ws_before_ndata {
3110 return Err(self.scan.err(
3111 "whitespace is required before `NDATA` (XML 1.0 § 4.2.2 [76])"
3112 ));
3113 }
3114 if is_param {
3115 return Err(self.scan.err(
3116 "NDATA annotation is not allowed on parameter entities (XML 1.0 § 4.2 [74])"
3117 ));
3118 }
3119 // XML 1.0 § 4.2.2 [73] [GEDecl]:
3120 // GEDecl ::= '<!ENTITY' S Name S EntityDef S? '>'
3121 // EntityDef ::= EntityValue | (ExternalID NDataDecl?)
3122 // NDataDecl is only legal when EntityDef is an ExternalID
3123 // — `<!ENTITY ge "literal" NDATA n>` is a fatal error.
3124 if !is_external {
3125 return Err(self.scan.err(
3126 "NDATA is only allowed on external (SYSTEM/PUBLIC) general \
3127 entities, not on internal EntityValue declarations \
3128 (XML 1.0 § 4.2.2 [73])"
3129 ));
3130 }
3131 self.scan.skip_n(5);
3132 self.scan.expect_ws()?;
3133 self.scan.skip_ws();
3134 let ndata_bytes = self.scan.scan_name_bytes()?;
3135 ent_ndata = Some(String::from_utf8_lossy(&ndata_bytes).into_owned());
3136 self.scan.skip_ws();
3137 // Record the unparsed-entity declaration for XSLT 1.0
3138 // §12.4 `unparsed-entity-uri()` / `-public-id()`. Both the
3139 // SYSTEM identifier (the URI a non-XML processor fetches)
3140 // and the optional PUBLIC identifier are kept. First decl
3141 // wins (XML 1.0 §4.2 — earliest binding).
3142 if let Some(sys) = &external_system_id {
3143 self.dtd.unparsed_entities
3144 .entry(name.clone())
3145 .or_insert_with(|| sup_xml_tree::UnparsedEntity {
3146 system_id: sys.clone(),
3147 public_id: external_public_id.clone(),
3148 });
3149 }
3150 is_unparsed = true;
3151 }
3152 // Track external general-entity names for `libxml2_compat`
3153 // mode: references to these names should silently expand to
3154 // empty rather than erroring "undefined entity," matching
3155 // libxml2's behaviour when the external file isn't loaded.
3156 // Parameter entities aren't tracked here (PE references are
3157 // a separate beast handled in the internal-subset loop).
3158 if is_external && !is_unparsed {
3159 // If a resolver is configured, ask it for the entity's
3160 // bytes and install them as the replacement text. The
3161 // resolver is the caller's opt-in to external loading;
3162 // its absence means we keep the historical no-load
3163 // behaviour and just record the name so libxml2_compat
3164 // mode can silently skip references.
3165 //
3166 // Unparsed (NDATA) entities are skipped here entirely:
3167 // XML 1.0 §4.4.4 forbids them from appearing as general
3168 // entity references in content (they're addressable only
3169 // through `unparsed-entity-uri()` and ENTITY-typed
3170 // attribute values), so there's nothing for the parser
3171 // to load. Asking the resolver would also be wrong —
3172 // the SYSTEM id points at a binary (e.g. an image).
3173 if !is_param && self.scan.opts.external_resolver.is_some() {
3174 // External *general* entity with a resolver configured:
3175 // do NOT fetch it now. XML 1.0 § 4.4.3 loads a parsed
3176 // external general entity only when it is referenced, so
3177 // an unreferenced declaration must not perform I/O (and
3178 // eagerly fetching would be an XXE/SSRF vector). Record
3179 // the identifiers; `load_deferred_entity` resolves them
3180 // on the first `&name;` reference in content — but only
3181 // when `resolve_external_entities` is set. When it is
3182 // not (lxml's `resolve_entities='internal'` default), the
3183 // entity is left unloaded so a reference reports it
3184 // undefined, never inlining external content.
3185 if self.scan.opts.resolve_external_entities {
3186 let sys = external_system_id.as_deref().unwrap_or("");
3187 self.deferred_general_entities.insert(
3188 name.clone(),
3189 DeferredExternal {
3190 system_id: sys.to_string(),
3191 public_id: external_public_id.clone(),
3192 },
3193 );
3194 }
3195 self.entities.insert(name, EntityDecl {
3196 kind: EntityKind::ExternalUnloaded,
3197 declared_external,
3198 source_uri: None,
3199 });
3200 } else if let Some(resolver) = self.scan.opts.external_resolver.clone()
3201 .filter(|_| self.scan.opts.load_external_dtd || self.scan.opts.validating)
3202 {
3203 // Reached only for external *parameter* entities (external
3204 // general entities are handled above). Loading one fetches
3205 // an attacker-controllable SYSTEM URI as part of DTD
3206 // processing, so it is gated behind the same opt-in as the
3207 // external DTD subset (`load_external_dtd`, or `validating`)
3208 // — not merely the presence of a resolver. Without the
3209 // opt-in the PE is left unloaded (see the parameter arm
3210 // below), so a `%pe;` reference is skipped rather than
3211 // performing I/O (XXE/SSRF, incl. blind/out-of-band XXE).
3212 let sys = external_system_id.as_deref().unwrap_or("");
3213 let pid = external_public_id.as_deref();
3214 // XML 1.0 § 4.2.2 + errata E18: choose the base URI
3215 // by entity kind. Parameter-entity declarations
3216 // resolve their SYSTEM URIs against the containing
3217 // entity (the most-deeply-nested PE we're currently
3218 // reading from). General-entity declarations
3219 // resolve against the *document* URL — even when
3220 // declared inside a deeply-nested external PE —
3221 // which is the unusual rule E18 fixes. Falling back
3222 // to `opts.base_url` covers the "not inside any
3223 // entity" case for both kinds.
3224 let base: Option<String> = if is_param {
3225 self.scan.current_base_uri()
3226 .map(str::to_string)
3227 .or_else(|| self.scan.opts.base_url.clone())
3228 } else {
3229 self.scan.opts.base_url.clone()
3230 };
3231 let absolute = resolve_uri(sys, base.as_deref());
3232 match resolver.resolve(pid, &absolute, base.as_deref()) {
3233 Ok(bytes) => {
3234 // XML 1.0 §4.3.3: external parsed entities
3235 // may be in any encoding the
3236 // EntityResolver returned bytes for —
3237 // typically UTF-8, but UTF-16 / UTF-32 /
3238 // any documented encoding is legal. Detect
3239 // from the BOM / first-bytes pattern and
3240 // transcode to UTF-8 (validation falls out
3241 // of the transcoder). This mirrors what
3242 // the main document goes through in
3243 // `parse_bytes`.
3244 // `transcode_to_utf8` short-circuits the
3245 // UTF-8-detected path *without* validating
3246 // — the bytes are returned as-is. For
3247 // resolver-supplied input we own the
3248 // validation, so re-run `from_utf8` on the
3249 // result. Two distinct error modes:
3250 // 1. transcode itself failed (malformed
3251 // UTF-16 / UTF-32 / unsupported encoding)
3252 // 2. transcode succeeded but the bytes
3253 // weren't valid UTF-8 (the no-BOM,
3254 // no-text-decl path)
3255 let transcoded: std::result::Result<Vec<u8>, String> =
3256 match crate::encoding::transcode_to_utf8(&bytes) {
3257 Ok(c) => Ok(c.into_owned()),
3258 Err(e) => Err(e.message.clone()),
3259 };
3260 let value = transcoded.and_then(|v| {
3261 String::from_utf8(v).map_err(|e| e.to_string())
3262 });
3263 match value {
3264 Ok(v) => {
3265 let decl = EntityDecl {
3266 kind: EntityKind::ExternalLoaded(v),
3267 declared_external,
3268 source_uri: Some(absolute.clone()),
3269 };
3270 if is_param {
3271 self.parameter_entities.insert(name, decl);
3272 } else {
3273 self.entities.insert(name, decl);
3274 }
3275 }
3276 Err(msg) => {
3277 let err = self.scan.err_with_level(
3278 ErrorLevel::Error,
3279 format!(
3280 "external entity '&{name};' is not valid UTF-8 \
3281 (system_id={sys:?}): {msg}"
3282 ),
3283 );
3284 self.maybe_recover(err)?;
3285 if !is_param {
3286 self.entities.insert(name, EntityDecl {
3287 kind: EntityKind::ExternalUnloaded,
3288 declared_external,
3289 source_uri: None,
3290 });
3291 }
3292 }
3293 }
3294 }
3295 Err(e) => {
3296 // Resolver said no (or failed to load). In
3297 // recover mode, log + continue so the rest
3298 // of the document still parses; in strict
3299 // mode, return a fatal error.
3300 let err = self.scan.err_with_level(
3301 ErrorLevel::Error,
3302 format!(
3303 "external resolver failed to load entity '&{name};' \
3304 (system_id={sys:?}, public_id={pid:?}): {e}"
3305 ),
3306 );
3307 self.maybe_recover(err)?;
3308 // Recovery: still register the name so
3309 // libxml2_compat can silently skip refs.
3310 if !is_param {
3311 self.entities.insert(name, EntityDecl {
3312 kind: EntityKind::ExternalUnloaded,
3313 declared_external,
3314 source_uri: None,
3315 });
3316 }
3317 }
3318 }
3319 } else if self.scan.opts.load_external_dtd && !is_param {
3320 // No resolver, but external loading is opted-in via
3321 // `load_external_dtd` (the same XXE-security gate
3322 // used for DTD subset loading). Read the file
3323 // pointed at by SYSTEM, resolving relative paths
3324 // against `base_url` when set. General entities
3325 // only — parameter entities aren't expanded here.
3326 let sys = external_system_id.as_deref().unwrap_or("");
3327 // E18 rule: general-entity decls resolve against
3328 // the document URL even when declared inside an
3329 // external PE. `current_base_uri()` is therefore
3330 // deliberately *not* consulted here.
3331 let base = self.scan.opts.base_url.clone();
3332 let absolute = resolve_uri(sys, base.as_deref());
3333 match read_external_entity_bytes(&absolute, None)
3334 .and_then(|b| String::from_utf8(b).map_err(|e| e.to_string()))
3335 {
3336 Ok(value) => {
3337 // Replacement text is held in the entities
3338 // map and replayed via push_entity_stream
3339 // when a `&name;` reference is encountered.
3340 // Any XML well-formedness errors in the
3341 // file surface at the reference site, not
3342 // here at declaration time.
3343 self.entities.insert(name, EntityDecl {
3344 kind: EntityKind::ExternalLoaded(value),
3345 declared_external,
3346 source_uri: Some(absolute),
3347 });
3348 }
3349 Err(msg) => {
3350 // File not found, unreadable, or not UTF-8.
3351 // Log a recovered warning and fall back to
3352 // the historical "register name, refs skip"
3353 // behaviour so the rest of the document
3354 // still parses.
3355 self.recovered_errors.push(XmlError::new(
3356 ErrorDomain::Parser,
3357 ErrorLevel::Warning,
3358 format!("external entity '&{name};' SYSTEM {sys:?} not loaded: {msg}"),
3359 ));
3360 self.entities.insert(name, EntityDecl {
3361 kind: EntityKind::ExternalUnloaded,
3362 declared_external,
3363 source_uri: None,
3364 });
3365 }
3366 }
3367 } else if !is_param {
3368 self.entities.insert(name, EntityDecl {
3369 kind: EntityKind::ExternalUnloaded,
3370 declared_external,
3371 source_uri: None,
3372 });
3373 } else {
3374 // External parameter entity left unloaded (no resolver, or
3375 // external DTD loading not opted into). Record it so a
3376 // later `%pe;` reference is silently skipped rather than
3377 // reported undefined — matching a libxml2 parser that does
3378 // not read external parameter entities by default.
3379 self.parameter_entities.insert(name, EntityDecl {
3380 kind: EntityKind::ExternalUnloaded,
3381 declared_external,
3382 source_uri: None,
3383 });
3384 }
3385 }
3386 // Record the general/parameter entity for the DTD object model
3387 // and serializer, in source order; first declaration wins.
3388 if !self.dtd.entities.iter().any(|e| e.name == model_name && e.parameter == is_param) {
3389 let idx = self.dtd.entities.len();
3390 self.dtd.entities.push(crate::dtd::model::EntityDecl {
3391 name: model_name,
3392 parameter: is_param,
3393 orig: ent_orig,
3394 content: ent_content,
3395 system_id: external_system_id.clone(),
3396 public_id: external_public_id.clone(),
3397 ndata: ent_ndata,
3398 });
3399 self.dtd.decl_order.push(crate::dtd::model::DeclRef::Entity(idx));
3400 }
3401 self.scan.expect(b'>')
3402 }
3403
3404 /// XML 1.0 § 3.3 [52] [AttlistDecl]:
3405 /// '<!ATTLIST' S Name AttDef* S? '>'
3406 /// AttDef ::= S Name S AttType S DefaultDecl
3407 /// AttType ::= StringType | TokenizedType | EnumeratedType
3408 /// StringType ::= 'CDATA'
3409 /// TokenizedType ::= 'ID'|'IDREF'|'IDREFS'|'ENTITY'|'ENTITIES'|'NMTOKEN'|'NMTOKENS'
3410 /// EnumeratedType ::= NotationType | Enumeration
3411 /// NotationType ::= 'NOTATION' S '(' S? Name (S? '|' S? Name)* S? ')'
3412 /// Enumeration ::= '(' S? Nmtoken (S? '|' S? Nmtoken)* S? ')'
3413 /// DefaultDecl ::= '#REQUIRED' | '#IMPLIED' | (('#FIXED' S)? AttValue)
3414 fn parse_attlist_decl(&mut self) -> Result<()> {
3415 use crate::dtd::AttDecl;
3416
3417 self.scan.skip_n(9); // "<!ATTLIST"
3418 self.expect_ws_with_pe()?;
3419 let elem_name = self.scan.scan_name()?.into_owned();
3420 // Attribute definitions — there may be zero or many.
3421 let mut decls: Vec<AttDecl> = Vec::new();
3422 loop {
3423 self.skip_ws_and_pe_refs()?;
3424 if self.scan.peek() == Some(b'>') {
3425 self.scan.advance();
3426 if !decls.is_empty() {
3427 self.dtd.add_attlist(elem_name, decls);
3428 }
3429 return Ok(());
3430 }
3431 let attr_name = self.scan.scan_name()?.into_owned();
3432 self.expect_ws_with_pe()?;
3433 let att_type = self.parse_att_type()?;
3434 self.expect_ws_with_pe()?;
3435 let default = self.parse_att_default()?;
3436 decls.push(AttDecl { name: attr_name, att_type, default });
3437 }
3438 }
3439
3440 /// One AttType per § 3.3.1. Rejects the SGML-only types
3441 /// NUTOKEN / NUTOKENS / NUMBER / NUMBERS / NAME / NAMES that
3442 /// XML 1.0 explicitly forbids.
3443 fn parse_att_type(&mut self) -> Result<crate::dtd::AttType> {
3444 use crate::dtd::AttType;
3445
3446 // Enumerated types start with `(` (or `NOTATION`).
3447 if self.scan.peek() == Some(b'(') {
3448 self.scan.advance();
3449 let mut values: Vec<String> = Vec::new();
3450 loop {
3451 self.scan.skip_ws();
3452 // Nmtoken = (NameChar)+ — XML 1.0 § 2.3 [7]. Unlike
3453 // Name it has no NameStartChar restriction, so
3454 // `(0|35a|...)` is valid in an attribute-enum decl.
3455 values.push(self.scan.scan_nmtoken()?.into_owned());
3456 self.scan.skip_ws();
3457 match self.scan.peek() {
3458 Some(b')') => { self.scan.advance(); return Ok(AttType::Enumeration(values)); }
3459 Some(b'|') => { self.scan.advance(); }
3460 _ => return Err(self.scan.err(
3461 "invalid enumerated attribute type — expected `|` or `)` (XML 1.0 § 3.3.1 [59])"
3462 )),
3463 }
3464 }
3465 }
3466 // Otherwise it must be one of the named types. Scan a Name
3467 // and check it against the allowed set.
3468 let kw = self.scan.scan_name_bytes()?;
3469 match &kw[..] {
3470 b"CDATA" => Ok(AttType::CData),
3471 b"ID" => Ok(AttType::Id),
3472 b"IDREF" => Ok(AttType::IdRef),
3473 b"IDREFS" => Ok(AttType::IdRefs),
3474 b"ENTITY" => Ok(AttType::Entity),
3475 b"ENTITIES" => Ok(AttType::Entities),
3476 b"NMTOKEN" => Ok(AttType::Nmtoken),
3477 b"NMTOKENS" => Ok(AttType::Nmtokens),
3478 b"NOTATION" => {
3479 self.scan.expect_ws()?;
3480 self.scan.skip_ws();
3481 self.scan.expect(b'(')?;
3482 let mut values: Vec<String> = Vec::new();
3483 loop {
3484 self.scan.skip_ws();
3485 values.push(self.scan.scan_name()?.into_owned());
3486 self.scan.skip_ws();
3487 match self.scan.peek() {
3488 Some(b')') => { self.scan.advance(); return Ok(AttType::Notation(values)); }
3489 Some(b'|') => { self.scan.advance(); }
3490 _ => return Err(self.scan.err(
3491 "invalid NOTATION enumeration — expected `|` or `)`"
3492 )),
3493 }
3494 }
3495 }
3496 other => Err(self.scan.err(format!(
3497 "invalid attribute type '{}' — not allowed in XML 1.0 (SGML-only or unknown); \
3498 valid types are CDATA, ID, IDREF, IDREFS, ENTITY, ENTITIES, NMTOKEN, NMTOKENS, \
3499 NOTATION, or `(enum)` (XML 1.0 § 3.3.1)",
3500 String::from_utf8_lossy(other)
3501 ))),
3502 }
3503 }
3504
3505 /// One DefaultDecl per § 3.3.2. Rejects SGML-only `#CURRENT`
3506 /// and `#CONREF`. When the default is a literal value
3507 /// (`#FIXED "..."` or just `"..."`), validates the value as if
3508 /// it were a document-body attribute value: no bare `<`, no bare
3509 /// `&`, entity refs must be defined and non-recursive, and
3510 /// external entity refs are forbidden (§ 4.4.4).
3511 fn parse_att_default(&mut self) -> Result<crate::dtd::AttDefault> {
3512 use crate::dtd::AttDefault;
3513
3514 if self.scan.peek() == Some(b'#') {
3515 self.scan.advance();
3516 let kw = self.scan.scan_name_bytes()?;
3517 match &kw[..] {
3518 b"REQUIRED" => Ok(AttDefault::Required),
3519 b"IMPLIED" => Ok(AttDefault::Implied),
3520 b"FIXED" => {
3521 self.scan.expect_ws()?;
3522 self.scan.skip_ws();
3523 let v = self.validate_att_default_value()?;
3524 Ok(AttDefault::Fixed(v))
3525 }
3526 other => Err(self.scan.err(format!(
3527 "invalid attribute default '#{}' — must be #REQUIRED, #IMPLIED, or #FIXED (XML 1.0 § 3.3.2 [60])",
3528 String::from_utf8_lossy(other)
3529 ))),
3530 }
3531 } else {
3532 let v = self.validate_att_default_value()?;
3533 Ok(AttDefault::Default(v))
3534 }
3535 }
3536
3537 /// Read a quoted ATTLIST default value and run the same syntactic
3538 /// validation as document-body attribute values:
3539 /// XML 1.0 § 3.1 / § 4.1 / § 4.4.4 — no `<`, no bare `&`, no
3540 /// external/cyclic/undefined entity references.
3541 fn validate_att_default_value(&mut self) -> Result<String> {
3542 let q = match self.scan.advance() {
3543 Some(b @ (b'"' | b'\'')) => b,
3544 _ => return Err(self.scan.err("expected quoted ATTLIST default value")),
3545 };
3546 let val_start = self.scan.cur_pos();
3547 match memchr(q, self.scan.cur_tail()) {
3548 None => Err(self.scan.err("unterminated ATTLIST default value")),
3549 Some(off) => {
3550 let val_end = val_start + off;
3551 // Synthesise an `attr="value"` slice so we can reuse
3552 // validate_attrs_syntax. The leading name is a dummy.
3553 let value_bytes = self.scan.cur_slice(val_start, val_end).into_owned();
3554 let mut buf: Vec<u8> = b"_=".to_vec();
3555 buf.push(q);
3556 buf.extend_from_slice(&value_bytes);
3557 buf.push(q);
3558 let inside_external = self.scan.current_base_uri().is_some();
3559 validate_attrs_syntax(&buf, &self.scan.opts, &self.entities, inside_external)
3560 .map_err(|msg| self.scan.err(msg))?;
3561 self.scan.cur_set_pos(val_end + 1);
3562 Ok(String::from_utf8_lossy(&value_bytes).into_owned())
3563 }
3564 }
3565 }
3566
3567 /// XML 1.0 § 3.2 [45] [elementdecl]:
3568 /// '<!ELEMENT' S Name S contentspec S? '>'
3569 /// contentspec ::= 'EMPTY' | 'ANY' | Mixed | children
3570 /// We validate the keyword EMPTY/ANY directly; for Mixed and
3571 /// children we run a balanced-paren check that verifies the
3572 /// shape without building the full AST.
3573 fn parse_element_decl(&mut self) -> Result<()> {
3574 use crate::dtd::{ContentModel, ElementDecl};
3575
3576 self.scan.skip_n(9); // "<!ELEMENT"
3577 self.expect_ws_with_pe()?;
3578 let name = self.scan.scan_name()?.into_owned();
3579 self.expect_ws_with_pe()?;
3580 // contentspec
3581 let content = match self.scan.peek() {
3582 Some(b'(') => self.parse_content_model()?,
3583 Some(_) => {
3584 let kw = self.scan.scan_name_bytes()?;
3585 match &kw[..] {
3586 b"EMPTY" => ContentModel::Empty,
3587 b"ANY" => ContentModel::Any,
3588 other => return Err(self.scan.err(format!(
3589 "invalid content model '{}' — must be EMPTY, ANY, or `(...)` (XML 1.0 § 3.2 [46])",
3590 String::from_utf8_lossy(other)
3591 ))),
3592 }
3593 }
3594 None => return Err(self.scan.err("unterminated <!ELEMENT> declaration")),
3595 };
3596 self.skip_ws_and_pe_refs()?;
3597 self.scan.expect(b'>')?;
3598 self.dtd.add_element(ElementDecl { name, content });
3599 Ok(())
3600 }
3601
3602 /// Parse a parenthesised content model: either Mixed (starts with
3603 /// `(#PCDATA`) or children (Names with `,`/`|` separators).
3604 fn parse_content_model(&mut self) -> Result<crate::dtd::ContentModel> {
3605 use crate::dtd::{ContentModel, Group, GroupKind, Occurrence};
3606
3607 self.scan.expect(b'(')?;
3608 self.skip_ws_and_pe_refs()?;
3609 // Mixed: `(#PCDATA (| Name)* )*` or `(#PCDATA)`.
3610 if self.scan.starts_with(b"#PCDATA") {
3611 self.scan.skip_n(b"#PCDATA".len());
3612 let mut choices: Vec<String> = Vec::new();
3613 loop {
3614 self.skip_ws_and_pe_refs()?;
3615 match self.scan.peek() {
3616 Some(b')') => {
3617 self.scan.advance();
3618 // XML 1.0 § 3.2.2 [51] Mixed has TWO shapes:
3619 // '(' S? '#PCDATA' (S? '|' S? Name)* S? ')*'
3620 // '(' S? '#PCDATA' S? ')'
3621 // The first form (with alternatives) REQUIRES
3622 // the trailing `*`; the bare `(#PCDATA)` form
3623 // forbids any quantifier.
3624 if choices.is_empty() {
3625 // `(#PCDATA)` — no `*` permitted.
3626 if self.scan.peek() == Some(b'*') {
3627 self.scan.advance();
3628 }
3629 } else {
3630 // `(#PCDATA | name | ...)` — `*` required.
3631 if self.scan.peek() != Some(b'*') {
3632 return Err(self.scan.err(
3633 "Mixed content model with alternatives must end \
3634 with `)*` (XML 1.0 § 3.2.2 [51])"
3635 ));
3636 }
3637 self.scan.advance();
3638 }
3639 return Ok(ContentModel::Mixed { choices });
3640 }
3641 Some(b'|') => {
3642 self.scan.advance();
3643 self.skip_ws_and_pe_refs()?;
3644 choices.push(self.scan.scan_name()?.into_owned());
3645 }
3646 _ => return Err(self.scan.err(
3647 "invalid mixed content model — expected `|` or `)` (XML 1.0 § 3.2.2 [51])"
3648 )),
3649 }
3650 }
3651 }
3652 // children: cp ((','|'|') cp)* — recursive paren structure.
3653 let mut items: Vec<crate::dtd::Particle> = Vec::new();
3654 items.push(self.parse_cp(0)?);
3655 // Determine separator on first occurrence (must stay consistent).
3656 let sep = {
3657 self.skip_ws_and_pe_refs()?;
3658 match self.scan.peek() {
3659 Some(b')') => 0u8,
3660 Some(b @ (b',' | b'|')) => b,
3661 _ => return Err(self.scan.err(
3662 "invalid children content model — expected `,`, `|`, or `)` (XML 1.0 § 3.2.1 [49/50])"
3663 )),
3664 }
3665 };
3666 if sep != 0 {
3667 loop {
3668 self.scan.advance(); // consume separator
3669 self.skip_ws_and_pe_refs()?;
3670 items.push(self.parse_cp(0)?);
3671 self.skip_ws_and_pe_refs()?;
3672 match self.scan.peek() {
3673 Some(b')') => break,
3674 Some(b) if b == sep => continue,
3675 Some(b) => return Err(self.scan.err(format!(
3676 "inconsistent separator in children content model — \
3677 got `{}`, expected `{}` or `)` (XML 1.0 § 3.2.1)",
3678 b as char, sep as char
3679 ))),
3680 None => return Err(self.scan.err("unterminated content model")),
3681 }
3682 }
3683 }
3684 self.scan.expect(b')')?;
3685 // Trailing quantifier `?`/`*`/`+` allowed.
3686 let outer_occ = read_occurrence(&mut self.scan);
3687 let kind = match sep {
3688 b'|' => GroupKind::Choice,
3689 // 0 (single item) or `,` → sequence.
3690 _ => GroupKind::Sequence,
3691 };
3692 Ok(ContentModel::Children(Group {
3693 kind,
3694 items,
3695 occur: outer_occ.unwrap_or(Occurrence::One),
3696 }))
3697 }
3698
3699 /// One [cp] — child production: Name | choice | seq, with an
3700 /// optional trailing `?` / `*` / `+`. Recurses into nested
3701 /// parens. Inside a nested group, `#PCDATA` is forbidden —
3702 /// mixed content is only legal at the outermost level
3703 /// (XML 1.0 § 3.2.2 [51]). `parse_content_model_inner` enforces this.
3704 fn parse_cp(&mut self, depth: u32) -> Result<crate::dtd::Particle> {
3705 use crate::dtd::{Item, Occurrence, Particle};
3706
3707 let item = if self.scan.peek() == Some(b'(') {
3708 Item::Group(Box::new(self.parse_content_model_inner(depth + 1)?))
3709 } else {
3710 Item::Name(self.scan.scan_name()?.into_owned())
3711 };
3712 let occur = read_occurrence(&mut self.scan).unwrap_or(Occurrence::One);
3713 Ok(Particle { item, occur })
3714 }
3715
3716 /// Parse a parenthesised group nested inside a content model.
3717 /// This is the same as `parse_content_model` *except* that
3718 /// `#PCDATA` is forbidden — XML 1.0 § 3.2.2 [51] [Mixed]
3719 /// requires `#PCDATA` to appear only at the outermost level
3720 /// (`(#PCDATA | Name | …)*`), never inside a nested group.
3721 /// Catches `<!ELEMENT doc ((#PCDATA))>`.
3722 fn parse_content_model_inner(&mut self, depth: u32) -> Result<crate::dtd::Group> {
3723 use crate::dtd::{Group, GroupKind, Occurrence, Particle};
3724
3725 if depth > MAX_CONTENT_MODEL_DEPTH {
3726 return Err(self.scan.err(format!(
3727 "content model nesting depth exceeds limit ({MAX_CONTENT_MODEL_DEPTH})"
3728 )));
3729 }
3730 self.scan.expect(b'(')?;
3731 self.skip_ws_and_pe_refs()?;
3732 if self.scan.starts_with(b"#PCDATA") {
3733 return Err(self.scan.err(
3734 "#PCDATA is only allowed at the top level of a content model, \
3735 not inside a nested group (XML 1.0 § 3.2.2 [51])"
3736 ));
3737 }
3738 let mut items: Vec<Particle> = Vec::new();
3739 items.push(self.parse_cp(depth)?);
3740 let sep = {
3741 self.skip_ws_and_pe_refs()?;
3742 match self.scan.peek() {
3743 Some(b')') => 0u8,
3744 Some(b @ (b',' | b'|')) => b,
3745 _ => return Err(self.scan.err(
3746 "invalid children content model — expected `,`, `|`, or `)`"
3747 )),
3748 }
3749 };
3750 if sep != 0 {
3751 loop {
3752 self.scan.advance();
3753 self.skip_ws_and_pe_refs()?;
3754 items.push(self.parse_cp(depth)?);
3755 self.skip_ws_and_pe_refs()?;
3756 match self.scan.peek() {
3757 Some(b')') => break,
3758 Some(b) if b == sep => continue,
3759 Some(b) => return Err(self.scan.err(format!(
3760 "inconsistent separator in content model — got `{}`, expected `{}` or `)`",
3761 b as char, sep as char
3762 ))),
3763 None => return Err(self.scan.err("unterminated content model")),
3764 }
3765 }
3766 }
3767 self.scan.expect(b')')?;
3768 let occur = read_occurrence(&mut self.scan).unwrap_or(Occurrence::One);
3769 let kind = if sep == b'|' { GroupKind::Choice } else { GroupKind::Sequence };
3770 Ok(Group { kind, items, occur })
3771 }
3772
3773 /// XML 1.0 § 4.7 [82] [NotationDecl]:
3774 /// '<!NOTATION' S Name S (ExternalID | PublicID) S? '>'
3775 fn parse_notation_decl(&mut self) -> Result<()> {
3776 self.scan.skip_n(10); // "<!NOTATION"
3777 self.expect_ws_with_pe()?;
3778 // Capture the notation name so we can enforce the NCName
3779 // constraint (no colons) when namespace-aware.
3780 let notation_name = self.scan.scan_name_bytes()?;
3781 if self.scan.opts.namespace_aware && notation_name.contains(&b':') {
3782 return Err(self.scan.err(format!(
3783 "notation name '{}' must be an NCName (no colon) under \
3784 XML Namespaces 1.0",
3785 String::from_utf8_lossy(¬ation_name)
3786 )));
3787 }
3788 self.expect_ws_with_pe()?;
3789 // External / Public ID — let the existing pubid-validating
3790 // helper handle it loosely. We just need to consume the
3791 // declaration.
3792 if self.scan.starts_with(b"PUBLIC") {
3793 self.scan.skip_n(6);
3794 self.expect_ws_with_pe()?;
3795 self.skip_pubid_literal()?;
3796 self.skip_ws_and_pe_refs()?;
3797 // Optional system literal (PublicID has no system; ExternalID has it).
3798 if matches!(self.scan.peek(), Some(b'"' | b'\'')) {
3799 self.skip_quoted()?;
3800 self.skip_ws_and_pe_refs()?;
3801 }
3802 } else if self.scan.starts_with(b"SYSTEM") {
3803 self.scan.skip_n(6);
3804 self.expect_ws_with_pe()?;
3805 self.skip_quoted()?;
3806 self.skip_ws_and_pe_refs()?;
3807 } else {
3808 return Err(self.scan.err(
3809 "<!NOTATION> requires PUBLIC or SYSTEM (XML 1.0 § 4.7 [82])"
3810 ));
3811 }
3812 self.scan.expect(b'>')
3813 }
3814
3815 // ── event readers ─────────────────────────────────────────────────────────
3816
3817 fn read_start_element(&mut self) -> Result<BytesEvent<'_, 'src>> {
3818 // Depth tracks unconditionally — `next()` consults it to decide
3819 // whether leading whitespace is content (depth > 0) or
3820 // ignorable inter-document space (depth == 0). Tying depth to
3821 // `skip_end_tag_check` was a bug: turning that flag on used to
3822 // accidentally swallow every inter-element whitespace text
3823 // event because depth never left 0.
3824 self.depth += 1;
3825 if self.depth > self.scan.opts.max_element_depth {
3826 self.depth -= 1;
3827 return Err(self.scan.err(format!("element nesting too deep (limit {})", self.scan.opts.max_element_depth)));
3828 }
3829 // Note that we have entered a root element. See `root_seen`
3830 // field docs and `dispatch_start_element` for what this
3831 // gates against (XML 1.0 § 2.1 [document] — exactly one
3832 // root).
3833 if self.depth == 1 {
3834 self.root_seen = true;
3835 }
3836 let track_stack = !self.scan.opts.skip_end_tag_check;
3837
3838 // Skip the `<`. Our dispatcher (`next()` → `dispatch_start_element()`)
3839 // is only entered when `bytes[cur_pos] == b'<'` was just verified,
3840 // so the byte is known to be present at `cur_pos`. Replacing the
3841 // safe `expect(b'<')` with a direct advance saves the bounds check
3842 // + comparison + Result construction at every start tag (~5M times
3843 // on swiss_prot; expect was 5.7% of profile self time).
3844 self.scan.cur_advance_pos(1);
3845 // Lazy: record the name's source byte range instead of
3846 // extracting it. scan_name_raw advances the scanner past the
3847 // name (same work as scan_name_bytes minus the slice copy).
3848 let (name_start_us, name_end_us) = self.scan.scan_name_raw()?;
3849 let name_start = name_start_us as u32;
3850 let name_end = name_end_us as u32;
3851 let start_stream_depth = self.scan.stream_depth();
3852 if track_stack {
3853 // Common case: scanner is on the original source — record
3854 // the byte range so end-tag matching can compare slices
3855 // without allocating. When the start tag is being read
3856 // from inside an entity-replacement stream, those offsets
3857 // refer to entity-stream bytes that aren't reachable via
3858 // `src_bytes()` — eagerly own the name instead.
3859 // Three cases for which storage to use:
3860 // 1. `stream_owned_names` is set — the streaming
3861 // wrapper rolls the source buffer between events,
3862 // so any byte range we captured here would point
3863 // at stale bytes by end-tag time. Must own.
3864 // 2. Reading from an entity-replacement stream — its
3865 // bytes don't live in `src_bytes()`, so byte
3866 // offsets can't be resolved at end-tag time.
3867 // Must own.
3868 // 3. Otherwise (the common slurped path): the source
3869 // bytes are pinned for the reader's lifetime, so
3870 // we can store a byte range and skip the alloc.
3871 let entry = if !self.scan.opts.stream_owned_names && self.scan.on_original_source() {
3872 ElementStackEntry::SourceRange(name_start, name_end)
3873 } else {
3874 // SAFETY: scan_name_raw advanced over a valid XML
3875 // Name; those bytes are valid UTF-8 by the same
3876 // invariant that lets `src_bytes()` be treated as
3877 // UTF-8 elsewhere in the parser.
3878 let bytes = &self.scan.cur_bytes()[name_start as usize..name_end as usize];
3879 ElementStackEntry::Owned(
3880 unsafe { std::str::from_utf8_unchecked(bytes) }.to_string()
3881 )
3882 };
3883 // The element being opened is a child of the current top —
3884 // mark the parent as having an element child before pushing
3885 // this frame (which starts with none of its own).
3886 if let Some(parent) = self.frame_saw_child.last_mut() {
3887 *parent = true;
3888 }
3889 self.element_stack.push(entry);
3890 self.element_streams.push(start_stream_depth);
3891 self.frame_saw_child.push(false);
3892 }
3893
3894 // Find the end of the start tag. Two-tier strategy:
3895 //
3896 // **Optimistic fast path (the common case):** XML allows `>`
3897 // inside attribute values literally (only `<` and `&` MUST be
3898 // escaped per XML 1.0 § 3.1) — but in practice nearly every
3899 // real-world document avoids it. We `memchr` for the first
3900 // `>`, then verify both quote characters appear in balanced
3901 // pairs before it. If yes, that `>` is the tag end. Two
3902 // SIMD-fast `memchr_iter` counts cost much less than the
3903 // per-attribute `memchr3 + memchr` loop the slow path uses.
3904 //
3905 // **Conservative fallback:** If a quote count comes out odd,
3906 // some `>` lives inside a quoted attribute value. Reset to
3907 // the original position and run the quote-aware scan to be
3908 // safe.
3909 let attrs_start = self.scan.cur_pos();
3910 let (attrs_end, self_closing) = 'scan: {
3911 // Fast path.
3912 let tail = self.scan.cur_tail();
3913 match memchr(b'>', tail) {
3914 None => return Err(self.scan.err("unterminated start tag")),
3915 Some(off) => {
3916 // Quote parity check. We need `dq % 2 == 0 && sq % 2 == 0`
3917 // — i.e. neither quote-kind has an unmatched occurrence
3918 // before this `>`. Counting both kinds in one
3919 // memchr2_iter pass is ~half the SIMD work of two
3920 // separate memchr_iter sweeps; for tags with no
3921 // quoted-value `>` (the common case in real XML), the
3922 // body runs only the attrs' quote bytes.
3923 let prefix = &tail[..off];
3924 let mut dq = 0u32;
3925 let mut sq = 0u32;
3926 for off2 in memchr::memchr2_iter(b'"', b'\'', prefix) {
3927 // SAFETY: memchr2_iter only yields valid offsets.
3928 match unsafe { *prefix.get_unchecked(off2) } {
3929 b'"' => dq += 1,
3930 _ => sq += 1,
3931 }
3932 }
3933 if (dq | sq) & 1 == 0 {
3934 // No quoted region could span this `>`.
3935 self.scan.cur_advance_pos(off);
3936 let self_closing = self.scan.cur_pos() > attrs_start
3937 && self.scan.cur_bytes()[self.scan.cur_pos() - 1] == b'/';
3938 let attrs_end = if self_closing { self.scan.cur_pos() - 1 } else { self.scan.cur_pos() };
3939 self.scan.cur_advance_pos(1);
3940 break 'scan (attrs_end, self_closing);
3941 }
3942 // Fall through to the conservative scan.
3943 }
3944 }
3945
3946 // Slow path (quote-aware). Quote-balance check above
3947 // failed, so some `>` lives inside a quoted value. Walk
3948 // attribute by attribute to find the real tag end. Cursor
3949 // is still at `attrs_start` (we never advanced).
3950 loop {
3951 let tail = self.scan.cur_tail();
3952 match memchr3(b'>', b'\'', b'"', tail) {
3953 None => return Err(self.scan.err("unterminated start tag")),
3954 Some(off) => {
3955 self.scan.cur_advance_pos(off);
3956 match self.scan.cur_bytes()[self.scan.cur_pos()] {
3957 b'>' => {
3958 let self_closing = self.scan.cur_pos() > attrs_start
3959 && self.scan.cur_bytes()[self.scan.cur_pos() - 1] == b'/';
3960 let attrs_end = if self_closing { self.scan.cur_pos() - 1 } else { self.scan.cur_pos() };
3961 self.scan.cur_advance_pos(1);
3962 break 'scan (attrs_end, self_closing);
3963 }
3964 quote @ (b'\'' | b'"') => {
3965 self.scan.cur_advance_pos(1);
3966 let inside = self.scan.cur_tail();
3967 match memchr(quote, inside) {
3968 None => return Err(self.scan.err("unterminated attribute value")),
3969 Some(off2) => self.scan.cur_advance_pos(off2 + 1),
3970 }
3971 }
3972 _ => unreachable!(),
3973 }
3974 }
3975 }
3976 }
3977 };
3978
3979 // Eagerly validate attribute syntax for spec compliance.
3980 // The lazy `BytesAttrs` iterator only checks attributes when
3981 // the user actually iterates them — but XML 1.0 well-
3982 // formedness requires us to reject malformed attributes
3983 // regardless of whether the application reads them. We do a
3984 // syntactic-only sweep here that errors on:
3985 // - duplicate attribute names (§ 3.1 WFC: Unique Att Spec)
3986 // - unquoted values (§ 3.1 [41] [AttValue])
3987 // - bare `<` or bare `&` inside values (§ 3.1 + § 4.1 WFC)
3988 // - invalid name-start chars on attr names (§ 2.3 [4])
3989 // The user can still iterate the attributes lazily
3990 // afterward — they'll re-walk the same bytes (now known
3991 // valid) without paying validation again.
3992 // Eager attribute validation. Two-tier strategy keeps the
3993 // common case cheap:
3994 //
3995 // 1. Cheap global scan: a single `memchr2` over the whole
3996 // attrs slice for `<` and `&`. `<` is forbidden in
3997 // attribute values regardless of position, so finding
3998 // it before the closing quote → error here. `&` means
3999 // the slice contains entity references that need the
4000 // deep walk; absent it, we can take the fast path.
4001 //
4002 // 2. Fast path (no `&`, no `<`): a smaller validator that
4003 // only checks attribute *structure* — names, `=`,
4004 // quotes, whitespace between attrs, duplicates — at
4005 // a fraction of the per-attr cost of the full walk.
4006 // Covers the vast majority of real-world documents:
4007 // data-XML (OSM, RSS, SOAP, configs) almost never uses
4008 // entity refs in attributes.
4009 //
4010 // 3. Full path (entity refs present): the existing
4011 // `validate_attrs_syntax` walks each attribute and
4012 // validates entity references against the document's
4013 // entity table.
4014 //
4015 // Empty / whitespace-only attrs slices skip everything —
4016 // no-attribute start tags are extremely common.
4017 //
4018 // The attrs byte range lives in whichever buffer the
4019 // scanner is reading from — `src_bytes()` for the common
4020 // case, an entity replacement stream when we're inside one.
4021 // Use `cur_bytes()` so an entity-stream start tag's attrs
4022 // get validated against the right buffer (XML 1.0 § 4.4.8 —
4023 // entity replacement text may contain start tags).
4024 let cur_for_validate = self.scan.cur_bytes();
4025 let attrs_slice = &cur_for_validate[attrs_start..attrs_end];
4026 if !self.scan.opts.skip_attr_validation
4027 && !attrs_slice.is_empty()
4028 && !attrs_slice.iter().all(|&b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
4029 {
4030 // Tier 1: any `<` in the slice is fatal regardless of
4031 // position (it's forbidden in attribute values *and*
4032 // cannot appear between attributes either — the start
4033 // tag would have terminated at it).
4034 if let Some(off) = memchr(b'<', attrs_slice) {
4035 return Err(self.scan.err(format!(
4036 "'<' not allowed inside start tag (XML 1.0 § 3.1) at attrs offset {off}"
4037 )));
4038 }
4039 // Tier 2 vs 3. A clean (no-`&`) attrs slice goes
4040 // through the structure-only fast path; otherwise the
4041 // full validator handles entity refs.
4042 if memchr(b'&', attrs_slice).is_none() {
4043 validate_attrs_structure_fast(attrs_slice, &self.scan.opts)
4044 .map_err(|msg| self.scan.err(msg))?;
4045 } else {
4046 let inside_external = self.scan.current_base_uri().is_some();
4047 validate_attrs_syntax(
4048 attrs_slice,
4049 &self.scan.opts,
4050 &self.entities,
4051 inside_external,
4052 ).map_err(|msg| self.scan.err(msg))?;
4053 }
4054 }
4055
4056 if self_closing {
4057 // Depth always decrements (matching the unconditional ++
4058 // at function entry). Element-stack maintenance is gated
4059 // on track_stack since it's only useful for end-tag
4060 // matching.
4061 self.depth -= 1;
4062 if track_stack {
4063 self.element_stack.pop();
4064 self.element_streams.pop();
4065 self.frame_saw_child.pop();
4066 }
4067 self.state = NextState::PendingEnd(name_start, name_end);
4068 }
4069
4070 // BytesStartTag borrows from `src` for the common (hot) path.
4071 // When the start tag is parsed inside an entity-replacement
4072 // stream, `name_start` / `name_end` index into the entity
4073 // stream's bytes, not `src`, so we capture the name into
4074 // `owned_name` and zero out the source offsets (they're
4075 // meaningless in this branch). Attrs are likewise unreachable
4076 // from `src`, so we eagerly parse them here against a copy of
4077 // the entity-stream slice and stash the pairs on the start
4078 // tag — see [`BytesStartTag::entity_attrs`].
4079 let src = self.scan.src_bytes();
4080 let (out_name_start, out_name_end, out_attrs_start, out_attrs_end, owned_name, entity_attrs) =
4081 if self.scan.on_original_source() {
4082 (name_start, name_end, attrs_start as u32, attrs_end as u32, None, None)
4083 } else {
4084 let name_bytes = &self.scan.cur_bytes()[name_start as usize..name_end as usize];
4085 let owned_name = Some(Box::<[u8]>::from(name_bytes));
4086 // Copy the entity-stream attrs slice into an owned
4087 // buffer, then run the regular attribute scanner over
4088 // it. The pairs collected here outlive the entity
4089 // frame because we own them.
4090 let attrs_owned: Vec<u8> = self.scan.cur_bytes()
4091 [attrs_start..attrs_end].to_vec();
4092 let parsed: Option<Vec<(Vec<u8>, Vec<u8>)>> = if attrs_owned.is_empty() {
4093 None
4094 } else {
4095 let mut tmp = BytesAttrs {
4096 scan: Scanner::new(&attrs_owned, Cow::Borrowed(&self.scan.opts)),
4097 entities: &self.entities,
4098 expansion_bytes: &mut self.expansion_bytes,
4099 done: false,
4100 standalone_yes: self.standalone_yes,
4101 is_xml_11: self.is_xml_11,
4102 };
4103 let mut acc: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
4104 while let Some(item) = tmp.next() {
4105 let a = item?;
4106 acc.push((a.name.to_vec(), a.value.into_owned()));
4107 }
4108 if acc.is_empty() { None } else { Some(acc) }
4109 };
4110 (0, 0, 0, 0, owned_name, parsed)
4111 };
4112 // Record the `<` offset for downstream diagnostics (XSD validator
4113 // pinning issues to the source line of the offending element).
4114 // Entity-stream start tags carry no meaningful source offset —
4115 // signal that case with u32::MAX so the validator can fall back.
4116 self.last_start_offset = if self.scan.on_original_source() {
4117 name_start.saturating_sub(1)
4118 } else {
4119 u32::MAX
4120 };
4121
4122 // Eagerly drive the attribute scanner before emitting the event.
4123 //
4124 // Attribute iteration (`BytesStartTag::attrs`) is lazy by design —
4125 // a SAX-style consumer that only cares about element names should
4126 // pay no per-attr cost. But laziness means a SAX consumer that
4127 // never asks for attrs can step right over a malformed attribute
4128 // value (a char ref to a non-`Char` codepoint, a standalone=yes
4129 // entity-declared violation, etc.) and emit `Eof` as if the
4130 // document were well-formed. XML 1.0 § 4.1 makes those
4131 // violations not-WF, not "the consumer's problem to discover."
4132 //
4133 // Walking BytesAttrs to completion here forces the same checks
4134 // that the DOM `parse_bytes` path naturally runs. Cost: a
4135 // memchr3 SIMD pass over the attribute byte range per element,
4136 // plus per-attribute decode work that's identical to what a DOM
4137 // consumer pays anyway. Hot-path text scanning is untouched.
4138 if out_attrs_end > out_attrs_start {
4139 let attrs_slice = &src[out_attrs_start as usize..out_attrs_end as usize];
4140 let mut tmp_attrs = BytesAttrs {
4141 scan: Scanner::new(attrs_slice, Cow::Borrowed(&self.scan.opts)),
4142 entities: &self.entities,
4143 expansion_bytes: &mut self.expansion_bytes,
4144 done: false,
4145 standalone_yes: self.standalone_yes,
4146 is_xml_11: self.is_xml_11,
4147 };
4148 while let Some(item) = tmp_attrs.next() {
4149 item?;
4150 }
4151 }
4152
4153 Ok(BytesEvent::StartElement(BytesStartTag {
4154 src,
4155 name_start: out_name_start,
4156 name_end: out_name_end,
4157 owned_name,
4158 entity_attrs,
4159 attrs_start: out_attrs_start,
4160 attrs_end: out_attrs_end,
4161 entities: &self.entities,
4162 expansion_bytes: &mut self.expansion_bytes,
4163 opts: &self.scan.opts,
4164 standalone_yes: self.standalone_yes,
4165 is_xml_11: self.is_xml_11,
4166 }))
4167 }
4168
4169 fn read_end_element(&mut self) -> Result<BytesEvent<'_, 'src>> {
4170 // Save the cursor before any consumption — used by recovery
4171 // for "mismatched end tag" to rewind so the unread `</name>`
4172 // is processed again after a synthetic close-out of the
4173 // intermediate elements.
4174 let rewind_pos = self.scan.cur_pos();
4175
4176 // XML 1.0 § 4.3.2 WFC "Logical Structure": the end tag must be in
4177 // the same input stream as the start tag of the element it closes.
4178 if !self.scan.opts.skip_end_tag_check {
4179 if let Some(&start_depth) = self.element_streams.last() {
4180 let now = self.scan.stream_depth();
4181 if now != start_depth {
4182 return Err(self.scan.err(
4183 "end tag is in a different entity than its start tag — \
4184 entity replacement must contain matched element pairs \
4185 (XML 1.0 § 4.3.2 WFC 'Logical Structure')",
4186 ));
4187 }
4188 }
4189 }
4190 // Skip `</`. Our dispatcher entered `read_end_element` only after
4191 // verifying `bytes[cur_pos] == b'<'` and `bytes[cur_pos+1] == b'/'`,
4192 // so both bytes are known present at cur_pos and cur_pos+1.
4193 // Skips the two `expect(...)` calls and their bounds checks +
4194 // comparison + Result construction (~5M times on swiss_prot).
4195 self.scan.cur_advance_pos(2);
4196 let (name_start_us, name_end_us) = self.scan.scan_name_raw()?;
4197 let name_start = name_start_us as u32;
4198 let name_end = name_end_us as u32;
4199 self.scan.skip_ws();
4200 self.scan.expect(b'>')?;
4201
4202 // Depth always decrements (matches the unconditional ++ in
4203 // read_start_element). saturating_sub guards against the edge
4204 // case of a stray end tag with skip_end_tag_check on (rare;
4205 // would otherwise underflow).
4206 self.depth = self.depth.saturating_sub(1);
4207
4208 if !self.scan.opts.skip_end_tag_check {
4209 // `scan_name_raw` returns offsets into whichever input
4210 // stream the scanner is currently reading from — that's
4211 // either the original source OR an active entity-stream
4212 // frame. Slice from `cur_bytes()` so we read the right
4213 // buffer in both cases. XML 1.0 § 4.3.2 already requires
4214 // matching pairs to share a stream (enforced by the
4215 // depth check above), so `got` and the matching stack
4216 // entry are guaranteed to come from the same source.
4217 let src = self.scan.src_bytes();
4218 let cur = self.scan.cur_bytes();
4219 let got = &cur[name_start as usize..name_end as usize];
4220 // Look at the top entry by reference so we can compare
4221 // its bytes — which may live in `src` (SourceRange) or
4222 // in the owned String for entity-stream entries.
4223 let top_matches = self.element_stack.last()
4224 .map(|e| e.name_bytes(src) == got)
4225 .unwrap_or(false);
4226 if top_matches {
4227 self.element_stack.pop();
4228 self.element_streams.pop();
4229 self.frame_saw_child.pop();
4230 } else if let Some(top) = self.element_stack.last() {
4231 // SAFETY: Scanner invariant — element-name bytes are
4232 // valid UTF-8. Only used for the error message.
4233 let exp = unsafe { std::str::from_utf8_unchecked(top.name_bytes(src)) };
4234 let got_str = unsafe { std::str::from_utf8_unchecked(got) };
4235 let err = self.scan.err_with_level(
4236 ErrorLevel::Error,
4237 format!("mismatched end tag: expected '</{exp}>', got '</{got_str}>'"),
4238 ).with_code(crate::error::ErrorCode::TagNameMismatch);
4239 // Recovery (libxml2-style): if `got` matches an
4240 // element deeper in the stack, auto-close the
4241 // intermediates. We do this one element at a
4242 // time — synthesise ONE close, rewind the
4243 // cursor to before `</name>`, and let the next
4244 // next() call re-process. Each iteration peels
4245 // off one open element until either the names
4246 // match or the stack is empty.
4247 self.maybe_recover(err)?;
4248 // `maybe_recover` took `&mut self`, invalidating
4249 // the `src` / `cur` borrows we held above. Re-
4250 // acquire from the same buffers (positions are
4251 // stable) so the stack-search closure can compare
4252 // bytes again.
4253 let src = self.scan.src_bytes();
4254 let cur = self.scan.cur_bytes();
4255 let got = &cur[name_start as usize..name_end as usize];
4256 let stack_has_match = self.element_stack.iter()
4257 .any(|e| e.name_bytes(src) == got);
4258 if stack_has_match {
4259 // Rewind so the </name> is re-read after
4260 // the synth close.
4261 self.scan.cur_set_pos(rewind_pos);
4262 return Ok(self.synthesize_close());
4263 }
4264 // Name doesn't appear anywhere on the stack —
4265 // discard the spurious end tag and continue.
4266 // (We've already consumed `</name>` plus the
4267 // skip_ws + `>`; cursor is at the next event.)
4268 return Ok(self.synthesize_close());
4269 } else {
4270 // element_stack was empty — end tag with no
4271 // matching start. Recover by logging and dropping.
4272 let got_str = unsafe { std::str::from_utf8_unchecked(got) };
4273 let err = self.scan.err_with_level(
4274 ErrorLevel::Error,
4275 format!("unexpected end tag '</{got_str}>' with no open element"),
4276 );
4277 self.maybe_recover(err)?;
4278 // Recovery: just drop the orphaned end tag and
4279 // emit nothing for it. We're at depth 0; the
4280 // PendingEnd state isn't meaningful here, so
4281 // recurse into next() to fetch the next real
4282 // event.
4283 return self.next();
4284 }
4285 }
4286 // BytesEndTag borrows from `src` — if the end tag was parsed
4287 // inside an entity stream, `name_start`/`name_end` index into
4288 // that stream's bytes, NOT `src`. Returning the source-offset
4289 // pair would point at unrelated source bytes. Hand back an
4290 // empty name range in that case; structural bookkeeping
4291 // (depth, element_stack pop) is already correct.
4292 let src = self.scan.src_bytes();
4293 let (out_start, out_end) = if self.scan.on_original_source() {
4294 (name_start, name_end)
4295 } else {
4296 (0, 0)
4297 };
4298 Ok(BytesEvent::EndElement(BytesEndTag {
4299 src, name_start: out_start, name_end: out_end,
4300 }))
4301 }
4302
4303 fn read_comment(&mut self) -> Result<BytesEvent<'_, 'src>> {
4304 self.scan.expect_str(b"<!--")?;
4305 // Count only miscs the builder will actually attach as a
4306 // document-level orphan, so the prolog index stays in step with
4307 // the sibling chain (`remove_comments` drops the node).
4308 let is_prolog_misc =
4309 self.depth == 0 && !self.root_seen && !self.scan.opts.remove_comments;
4310 let start = self.scan.cur_pos();
4311 loop {
4312 match memchr(b'-', self.scan.cur_tail()) {
4313 None => return Err(self.scan.err("unterminated comment")),
4314 Some(off) => {
4315 self.scan.cur_advance_pos(off);
4316 if self.scan.starts_with(b"--") {
4317 let content = self.scan.cur_slice(start, self.scan.cur_pos());
4318 self.scan.skip_n(2);
4319 if self.scan.peek() != Some(b'>') {
4320 return Err(self.scan.err("'--' inside comment content is not allowed"));
4321 }
4322 self.scan.advance();
4323 if is_prolog_misc { self.prolog_misc_count += 1; }
4324 return Ok(BytesEvent::Comment(BytesComment { inner: content }));
4325 }
4326 self.scan.advance(); // lone `-`, keep going
4327 }
4328 }
4329 }
4330 }
4331
4332 fn read_cdata(&mut self) -> Result<BytesEvent<'_, 'src>> {
4333 self.scan.expect_str(b"<![CDATA[")?;
4334 let start = self.scan.cur_pos();
4335 loop {
4336 match memchr(b']', self.scan.cur_tail()) {
4337 None => return Err(self.scan.err("unterminated CDATA")),
4338 Some(off) => {
4339 self.scan.cur_advance_pos(off);
4340 if self.scan.starts_with(b"]]>") {
4341 // §2.11 EOL normalization applies inside CDATA
4342 // too — the spec frames it as happening before
4343 // parsing. The lazy wrapper keeps the common
4344 // (no-EOL-byte) case zero-copy.
4345 let content = self.scan.cur_slice(start, self.scan.cur_pos());
4346 let content = maybe_normalize_eol(
4347 content, self.is_xml_11, self.source_has_eol_candidate,
4348 );
4349 self.scan.skip_n(3);
4350 return Ok(BytesEvent::CData(BytesCData { inner: content }));
4351 }
4352 self.scan.advance(); // lone `]`, keep going
4353 }
4354 }
4355 }
4356 }
4357
4358 fn read_pi(&mut self) -> Result<BytesEvent<'_, 'src>> {
4359 self.scan.expect_str(b"<?")?;
4360 let is_prolog_misc =
4361 self.depth == 0 && !self.root_seen && !self.scan.opts.remove_pis;
4362 let target = self.scan.scan_name_bytes()?;
4363 if target.eq_ignore_ascii_case(b"xml") {
4364 return Err(self.scan.err("PI target 'xml' is reserved"));
4365 }
4366 // XML Namespaces 1.0 § 3 [NSPITarget]: PITarget must be an
4367 // NCName when namespace-aware — i.e. no colon anywhere.
4368 // Reject e.g. `<?a:b ?>`.
4369 if self.scan.opts.namespace_aware && target.contains(&b':') {
4370 return Err(self.scan.err(format!(
4371 "PI target '{}' must be an NCName (no colon) under XML \
4372 Namespaces 1.0",
4373 String::from_utf8_lossy(&target)
4374 )));
4375 }
4376 let content = if matches!(self.scan.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
4377 self.scan.skip_ws();
4378 let start = self.scan.cur_pos();
4379 loop {
4380 if self.scan.is_eof() { return Err(self.scan.err("unterminated PI")); }
4381 if self.scan.starts_with(b"?>") {
4382 let s = self.scan.cur_slice(start, self.scan.cur_pos());
4383 self.scan.skip_n(2);
4384 break s;
4385 }
4386 self.scan.advance();
4387 }
4388 } else {
4389 self.scan.expect_str(b"?>")?;
4390 Cow::Borrowed(&b""[..])
4391 };
4392 if is_prolog_misc { self.prolog_misc_count += 1; }
4393 Ok(BytesEvent::Pi(BytesPi { target_: target, content_: content }))
4394 }
4395
4396 /// Peek for an unresolved user-defined entity reference at the
4397 /// current scanner position (which must already be on `&`). If
4398 /// the upcoming bytes are `&NAME;` where NAME is neither
4399 /// predefined (`amp`/`lt`/`gt`/`quot`/`apos`) nor a numeric
4400 /// `#...;`, queue a `NextState::PendingEntityRef` carrying NAME
4401 /// and consume `&NAME;` from the scanner. Returns `Ok(true)`
4402 /// when the reference was queued, `Ok(false)` otherwise.
4403 ///
4404 /// Caller must have already verified `!opts.resolve_entities`
4405 /// and `scan.on_original_source()`. Numeric character refs
4406 /// (`A` / `A`) always expand inline regardless of
4407 /// `resolve_entities` — they're part of the character production,
4408 /// not the entity-reference machinery.
4409 fn try_queue_user_entity_ref(&mut self) -> Result<bool> {
4410 let bytes = self.scan.src_bytes();
4411 let amp_pos = self.scan.cur_pos();
4412 debug_assert_eq!(bytes.get(amp_pos), Some(&b'&'));
4413 let name_start = amp_pos + 1;
4414 if name_start >= bytes.len() { return Ok(false); }
4415 // Numeric refs always expand inline.
4416 if bytes[name_start] == b'#' { return Ok(false); }
4417 // Find the trailing `;` — name runs over NameChar bytes.
4418 let mut p = name_start;
4419 while p < bytes.len() {
4420 let b = bytes[p];
4421 // ASCII NameChar fast check. Non-ASCII bytes (>= 0x80)
4422 // are accepted; the byte reader's name validation has
4423 // already passed at this point, so any non-`;` sequence
4424 // here is a valid name continuation. Stop at the first
4425 // non-name byte and let the caller fall through to the
4426 // normal expand path, which will report a clean error
4427 // if the reference is malformed.
4428 if b == b';' { break; }
4429 let is_name_byte = matches!(b,
4430 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
4431 | b'_' | b'-' | b'.' | b':' | 0x80..=0xFF
4432 );
4433 if !is_name_byte { return Ok(false); }
4434 p += 1;
4435 }
4436 if p == name_start || p >= bytes.len() || bytes[p] != b';' {
4437 return Ok(false);
4438 }
4439 let name_end = p;
4440 // Predefined entities always expand.
4441 let name = &bytes[name_start..name_end];
4442 if matches!(name, b"amp" | b"lt" | b"gt" | b"quot" | b"apos") {
4443 return Ok(false);
4444 }
4445 // Queue + consume `&NAME;`.
4446 self.state = NextState::PendingEntityRef(name_start as u32, name_end as u32);
4447 self.scan.cur_set_pos(name_end + 1); // past the closing `;`
4448 Ok(true)
4449 }
4450
4451 /// With the cursor parked on the `&` of a content reference, peek
4452 /// the entity name (without consuming) and, if it names a deferred
4453 /// external general entity, load it now — so the subsequent
4454 /// `expand_reference_bytes` sees `ExternalLoaded` replacement text.
4455 /// A no-op for builtins, numeric refs, and already-resolved names.
4456 fn maybe_load_deferred_entity_at_cursor(&mut self) -> Result<()> {
4457 if self.deferred_general_entities.is_empty() {
4458 return Ok(());
4459 }
4460 let bytes = self.scan.cur_bytes();
4461 let pos = self.scan.cur_pos();
4462 if bytes.get(pos) != Some(&b'&') {
4463 return Ok(());
4464 }
4465 let name_start = pos + 1;
4466 // Numeric character references never name an entity.
4467 if bytes.get(name_start) == Some(&b'#') {
4468 return Ok(());
4469 }
4470 let mut p = name_start;
4471 while p < bytes.len() && bytes[p] != b';' {
4472 p += 1;
4473 }
4474 if p >= bytes.len() || p == name_start {
4475 return Ok(());
4476 }
4477 let name = match std::str::from_utf8(&bytes[name_start..p]) {
4478 Ok(s) => s.to_string(),
4479 Err(_) => return Ok(()),
4480 };
4481 self.load_deferred_entity(&name)
4482 }
4483
4484 /// Resolve a deferred external general entity through the configured
4485 /// `external_resolver`, upgrading its table entry from
4486 /// `ExternalUnloaded` to `ExternalLoaded`. Idempotent: once the
4487 /// deferred record is consumed, later references reuse the loaded
4488 /// text. A resolver failure on a *referenced* entity is a parse
4489 /// error in strict mode (recovered to `ExternalUnloaded` otherwise),
4490 /// mirroring how a referenced-but-unloadable entity behaved when
4491 /// loading was eager.
4492 fn load_deferred_entity(&mut self, name: &str) -> Result<()> {
4493 let Some(deferred) = self.deferred_general_entities.remove(name) else {
4494 return Ok(());
4495 };
4496 let Some(resolver) = self.scan.opts.external_resolver.clone() else {
4497 return Ok(());
4498 };
4499 let declared_external = self.entities.get(name)
4500 .map(|d| d.declared_external)
4501 .unwrap_or(false);
4502 // E18: general-entity SYSTEM ids resolve against the document URL.
4503 let base = self.scan.opts.base_url.clone();
4504 let absolute = resolve_uri(&deferred.system_id, base.as_deref());
4505 match resolver.resolve(deferred.public_id.as_deref(), &absolute, base.as_deref()) {
4506 Ok(raw) => {
4507 // XML 1.0 § 4.3.3: the external entity may be in any
4508 // documented encoding; detect + transcode to UTF-8, then
4509 // validate (transcode short-circuits the UTF-8 path
4510 // without validating).
4511 let value = match crate::encoding::transcode_to_utf8(&raw) {
4512 Ok(c) => String::from_utf8(c.into_owned()).map_err(|e| e.to_string()),
4513 Err(e) => Err(e.message.clone()),
4514 };
4515 match value {
4516 Ok(v) => {
4517 self.entities.insert(name.to_string(), EntityDecl {
4518 kind: EntityKind::ExternalLoaded(v),
4519 declared_external,
4520 source_uri: Some(absolute),
4521 });
4522 }
4523 Err(msg) => {
4524 let err = self.scan.err_with_level(
4525 ErrorLevel::Error,
4526 format!(
4527 "external entity '&{name};' is not valid UTF-8 \
4528 (system_id={:?}): {msg}",
4529 deferred.system_id
4530 ),
4531 );
4532 self.maybe_recover(err)?;
4533 }
4534 }
4535 }
4536 Err(e) => {
4537 let err = self.scan.err_with_level(
4538 ErrorLevel::Error,
4539 format!(
4540 "external resolver failed to load entity '&{name};' \
4541 (system_id={:?}, public_id={:?}): {e}",
4542 deferred.system_id, deferred.public_id
4543 ),
4544 );
4545 self.maybe_recover(err)?;
4546 }
4547 }
4548 Ok(())
4549 }
4550
4551 fn read_text(&mut self) -> Result<BytesEvent<'_, 'src>> {
4552 let start = self.scan.cur_pos();
4553
4554 // Raw-text mode: skip entity expansion and `]]>` checking, scan only
4555 // for the next `<`. The Text payload is the source slice verbatim
4556 // with `&…;` references left in place; callers expand on demand via
4557 // `unescape`. This is the apples-to-apples-with-quick-xml fast path
4558 // — single `memchr(b'<')` over the whole text body. The opt-in skips
4559 // §2.11 EOL normalization too: callers using `skip_entity_expansion`
4560 // are responsible for any normalization they need downstream.
4561 if self.scan.opts.skip_entity_expansion {
4562 match memchr(b'<', self.scan.cur_tail()) {
4563 None => {
4564 self.scan.cur_set_pos(self.scan.cur_len());
4565 }
4566 Some(off) => {
4567 self.scan.cur_advance_pos(off);
4568 }
4569 }
4570 return Ok(BytesEvent::Text(BytesText {
4571 inner: self.scan.cur_slice(start, self.scan.cur_pos()),
4572 }));
4573 }
4574
4575 // SIMD fast path: scan for `<`, `&`, or `]` in one pass. When we
4576 // stop at `<` (or EOF) without seeing any `&`, the result is
4577 // zero-copy (`cur_str` returns `Cow::Borrowed` for the source stream)
4578 // *unless* the segment contains EOL bytes that XML §2.11 requires
4579 // us to rewrite (`\r`, plus NEL/LS in XML 1.1) — in that case we
4580 // fall through to a normalized owned copy. The `gate` flag is
4581 // the one-shot source pre-scan: when the entire document
4582 // contains no EOL candidate byte the per-segment scan and the
4583 // rewrite branch both become dead code.
4584 let is_xml_11 = self.is_xml_11;
4585 let gate = self.source_has_eol_candidate;
4586 loop {
4587 let tail = self.scan.cur_tail();
4588 match memchr3(b'<', b'&', b']', tail) {
4589 None => {
4590 self.scan.cur_set_pos(self.scan.cur_len());
4591 let raw = self.scan.cur_slice(start, self.scan.cur_pos());
4592 return Ok(BytesEvent::Text(BytesText {
4593 inner: maybe_normalize_eol(raw, is_xml_11, gate),
4594 }));
4595 }
4596 Some(off) => {
4597 self.scan.cur_advance_pos(off);
4598 match self.scan.cur_bytes()[self.scan.cur_pos()] {
4599 b'<' => {
4600 let raw = self.scan.cur_slice(start, self.scan.cur_pos());
4601 return Ok(BytesEvent::Text(BytesText {
4602 inner: maybe_normalize_eol(raw, is_xml_11, gate),
4603 }));
4604 }
4605 b'&' => break, // fall through to expansion path
4606 b']' => {
4607 if self.scan.starts_with(b"]]>") {
4608 if self.scan.opts.recovery_mode {
4609 // Drop into the slow path with
4610 // the cursor still parked on
4611 // `]]>`; the slow path's `]`
4612 // arm handles the recovery
4613 // (push 3 bytes literal +
4614 // skip).
4615 break;
4616 }
4617 return Err(self.scan.err("']]>' not allowed in text content"));
4618 }
4619 self.scan.advance(); // lone `]` is fine
4620 }
4621 _ => unreachable!(),
4622 }
4623 }
4624 }
4625 }
4626
4627 // Slow path: entity found. Reuse the reader's text-decode scratch
4628 // buffer (grows monotonically over the parse) instead of allocating
4629 // a fresh `Vec::new()` per slow-path entry. We swap the buffer out
4630 // via `mem::take` to satisfy the borrow checker — `expand_reference_bytes`
4631 // wants `&mut self.scan` and `&mut buf` at the same time, which is
4632 // unsound with `&mut self.text_decode_buf`. The swap puts an empty
4633 // sentinel in place during the loop and restores the (possibly
4634 // larger-capacity) buffer at the end.
4635 let mut buf = std::mem::take(&mut self.text_decode_buf);
4636 buf.clear();
4637 // In-place + original-stream mode: skip copying the clean prefix
4638 // (bytes from `start` to current `cur_pos`) into `buf` — those
4639 // bytes are already at the right position in source. `buf` then
4640 // collects only the *decoded suffix* (entity expansions +
4641 // post-entity clean runs), and the final `compact_at` writes
4642 // that suffix starting at `prefix_end`, leaving the prefix
4643 // untouched. Saves one memcpy of `cur_pos - start` bytes per
4644 // slow-path entry — significant when the entity-bearing region
4645 // is preceded by a long clean run (typical real-world docs).
4646 let prefix_end = self.scan.cur_pos();
4647 let in_place_orig = self.scan.is_in_place() && self.scan.on_original_source();
4648 if !in_place_orig {
4649 append_text_segment(&self.scan, start, prefix_end, &mut buf, self.is_xml_11, self.source_has_eol_candidate);
4650 }
4651 let budget = self.scan.opts.max_entity_expansion_bytes;
4652 let depth = self.element_stack.len() as u32;
4653 loop {
4654 let tail = self.scan.cur_tail();
4655 match memchr3(b'<', b'&', b']', tail) {
4656 None => {
4657 // Consume rest of the current stream into `buf`.
4658 let end = self.scan.cur_len();
4659 let from = self.scan.cur_pos();
4660 append_text_segment(&self.scan, from, end, &mut buf, self.is_xml_11, self.source_has_eol_candidate);
4661 self.scan.cur_set_pos(end);
4662 // If the current stream was an entity replacement, pop it
4663 // and keep accumulating text from the next stream below —
4664 // but only if the element-stack depth still matches the
4665 // depth at push (XML 1.0 § 4.3.2 WFC 'Logical Structure').
4666 if let Some((name, depth_at_push)) = self.scan.top_entity_info() {
4667 if depth_at_push != depth {
4668 return Err(self.scan.err(format!(
4669 "entity '&{name};' contains unbalanced element markup — \
4670 element-stack depth was {depth_at_push} when the entity \
4671 was expanded but is {depth} at its end \
4672 (XML 1.0 § 4.3.2 WFC 'Logical Structure')"
4673 )));
4674 }
4675 }
4676 if self.scan.try_pop_entity_stream() {
4677 continue;
4678 }
4679 break;
4680 }
4681 Some(off) => {
4682 let from = self.scan.cur_pos();
4683 append_text_segment(&self.scan, from, from + off, &mut buf, self.is_xml_11, self.source_has_eol_candidate);
4684 self.scan.cur_advance_pos(off);
4685 match self.scan.cur_bytes()[self.scan.cur_pos()] {
4686 b'<' => break,
4687 b'&' => {
4688 // Bare `&` recovery: a `&` followed by
4689 // something that isn't `#` or a name-
4690 // start character can't be a valid
4691 // reference. In recover mode, keep the
4692 // `&` as a literal byte (preserving
4693 // user data, unlike libxml2 which
4694 // silently DROPS the `&`).
4695 let next = self.scan.peek_at(1);
4696 let is_ref_start = matches!(
4697 next,
4698 Some(b'#')
4699 | Some(b'A'..=b'Z')
4700 | Some(b'a'..=b'z')
4701 | Some(b'_')
4702 | Some(b':')
4703 | Some(0x80..=0xFF) // non-ASCII NameStart
4704 );
4705 if !is_ref_start && self.scan.opts.recovery_mode {
4706 let err = self.scan.err_with_level(
4707 ErrorLevel::Error,
4708 "bare '&' in text content — kept literal \
4709 (XML 1.0 § 4.1 [Reference])",
4710 );
4711 self.recovered_errors.push(err);
4712 buf.push(b'&');
4713 self.scan.advance();
4714 } else if !self.scan.opts.resolve_entities
4715 && self.scan.on_original_source()
4716 && self.try_queue_user_entity_ref()?
4717 {
4718 // EntityRef queued; flush the
4719 // pre-reference Text content now and
4720 // let the next next() call emit the
4721 // ref.
4722 break;
4723 } else {
4724 // Resolve a deferred external general
4725 // entity (loaded lazily on first
4726 // reference) before expanding it.
4727 self.maybe_load_deferred_entity_at_cursor()?;
4728 expand_reference_bytes(
4729 &mut self.scan, &mut buf,
4730 &self.entities,
4731 &mut self.expansion_bytes, budget, depth,
4732 Some(&mut self.recovered_errors),
4733 self.pe_ref_in_internal_subset_seen,
4734 self.standalone_yes,
4735 self.is_xml_11,
4736 )?;
4737 }
4738 }
4739 b']' => {
4740 if self.scan.starts_with(b"]]>") {
4741 // Recovery: keep `]]>` literal in
4742 // the text content rather than
4743 // mangling surrounding bytes the
4744 // way libxml2 does.
4745 let err = self.scan.err_with_level(
4746 ErrorLevel::Error,
4747 "']]>' not allowed in text content — kept literal \
4748 (XML 1.0 § 2.4 [CharData])",
4749 );
4750 self.maybe_recover(err)?;
4751 buf.extend_from_slice(b"]]>");
4752 self.scan.skip_n(3);
4753 } else {
4754 buf.push(b']');
4755 self.scan.advance();
4756 }
4757 }
4758 _ => unreachable!(),
4759 }
4760 }
4761 }
4762 }
4763 // In-place mode: write the decoded bytes back into the source
4764 // buffer at `start..start + buf.len()` and emit a Cow::Borrowed
4765 // slice into that span. The "garbage tail" at
4766 // start + buf.len() .. cur_pos remains in the buffer but is
4767 // never re-read (the scanner has already advanced past it).
4768 //
4769 // The expansion fits only if `buf.len() <= cur_pos - start` —
4770 // i.e. the decoded form is no bigger than the source span it
4771 // came from. For XML 1.0 builtins (`&` → `&`, etc.) and
4772 // numeric char refs (`É` → `É`) the rule is satisfied by
4773 // construction. For user-defined `<!ENTITY>` references whose
4774 // replacement text is bigger than `&name;`, this is the
4775 // rejection site documented in the plan — we return Err.
4776 //
4777 // Only safe when we're still reading from the original source
4778 // (no active entity stream) — entity-stream bytes don't live in
4779 // the source buffer and `compact_at` would write to the wrong
4780 // place.
4781 if in_place_orig {
4782 // `buf` holds only the decoded *suffix* (the part after
4783 // the clean prefix that ends at `prefix_end`). The
4784 // suffix's original source span is `prefix_end..end_pos`
4785 // — we require the decoded suffix to fit in it. When
4786 // the prefix is short or absent this matches the old
4787 // "buf.len() <= end_pos - start" check; when the prefix
4788 // is long the new check is tighter (good — we'd corrupt
4789 // source by writing past `end_pos`).
4790 let end_pos = self.scan.cur_pos();
4791 let suffix_orig_len = end_pos.saturating_sub(prefix_end);
4792 if buf.len() > suffix_orig_len {
4793 let err = self.scan.err(format!(
4794 "entity expansion ({} bytes) exceeds source span ({} bytes); \
4795 parse_bytes_in_place requires expansion ≤ reference length. \
4796 Use parse_bytes for documents with expanding user-defined entities.",
4797 buf.len(), suffix_orig_len,
4798 ));
4799 self.text_decode_buf = buf;
4800 return Err(err);
4801 }
4802 // If the clean prefix carries any EOL-significant byte the
4803 // in-place compact would emit raw `\r` / NEL / LS to the
4804 // caller, violating §2.11. Rather than introduce a
4805 // shifting in-place rewrite (EOL never lengthens, but
4806 // representing the shrunk prefix would need a second
4807 // compact), fall back to a fresh owned Vec for the rare
4808 // "in-place + entity + EOL-in-prefix" case.
4809 let prefix_bytes = self.scan.src_slice(start, prefix_end);
4810 if self.source_has_eol_candidate
4811 && first_eol_byte(prefix_bytes, self.is_xml_11).is_some()
4812 {
4813 let mut out = Vec::with_capacity(prefix_bytes.len() + buf.len());
4814 append_eol_normalized(prefix_bytes, &mut out, self.is_xml_11);
4815 out.extend_from_slice(&buf);
4816 self.text_decode_buf = buf;
4817 return Ok(BytesEvent::Text(BytesText { inner: Cow::Owned(out) }));
4818 }
4819 // Write decoded suffix into source[prefix_end..prefix_end+buf.len()].
4820 // The clean prefix at source[start..prefix_end] is unchanged.
4821 let suffix_written = buf.len();
4822 if suffix_written > 0 {
4823 self.scan.compact_at(prefix_end, end_pos, &buf);
4824 }
4825 // Restore the scratch buffer for the next slow-path entry —
4826 // its capacity grows monotonically across the parse.
4827 self.text_decode_buf = buf;
4828 let total_len = (prefix_end - start) + suffix_written;
4829 return Ok(BytesEvent::Text(BytesText {
4830 inner: Cow::Borrowed(self.scan.src_slice(start, start + total_len)),
4831 }));
4832 }
4833 // Non-in-place path consumes `buf` into the event payload — the
4834 // caller (`alloc_cow_bytes_as_str` in arena_parser) copies it into
4835 // the bump arena and drops the Vec. We pay a fresh Vec next slow-
4836 // path entry; the in-place fast path is the one we optimise.
4837 Ok(BytesEvent::Text(BytesText { inner: Cow::Owned(buf) }))
4838 }
4839}
4840
4841/// Walk an entity reference chain from `name`, checking that every
4842/// reference resolves (XML 1.0 § 4.1 WFC: Entity Declared) and that
4843/// no cycle exists (§ 4.1 WFC: No Recursion). `chain` accumulates
4844/// the names currently being expanded; if we encounter a name
4845/// already on it, we have a cycle.
4846///
4847/// `inside_external` enables the XML 1.0 § 4.1 WFC: Entity Declared
4848/// carve-out: refs that appear within the external subset or a
4849/// parameter entity's replacement text are exempt from the WF rule
4850/// (at most a VC violation, which non-validating parsers tolerate).
4851/// When `true`, an undeclared `name` returns `Ok(())` instead of
4852/// erroring; the cycle check still applies if the entity is found.
4853fn check_entity_chain<'a>(
4854 name: &'a str,
4855 entities: &'a HashMap<String, EntityDecl>,
4856 chain: &mut Vec<&'a str>,
4857 inside_external: bool,
4858) -> std::result::Result<(), String> {
4859 if chain.contains(&name) {
4860 return Err(format!(
4861 "entity '&{name};' references itself (XML 1.0 § 4.1 WFC: No Recursion); \
4862 chain: {} -> {name}",
4863 chain.join(" -> ")
4864 ));
4865 }
4866 let predef = matches!(name, "amp" | "lt" | "gt" | "quot" | "apos");
4867 if predef { return Ok(()); }
4868 let value = match entities.get(name).and_then(|k| k.replacement()) {
4869 Some(v) => v,
4870 None => {
4871 if inside_external {
4872 // WFC: Entity Declared carve-out — refs inside an
4873 // external entity / external subset don't trigger
4874 // the WF rule. Accept silently.
4875 return Ok(());
4876 }
4877 return Err(format!(
4878 "undefined entity '&{name};' (XML 1.0 § 4.1 WFC: Entity Declared)"
4879 ));
4880 }
4881 };
4882 chain.push(name);
4883 // Scan the value for nested references AND for stray `&` / `<`
4884 // bytes that the replacement text would inject into the
4885 // including context. XML 1.0 § 4.5 says replacement text must
4886 // be well-formed CharData when included in attr values / element
4887 // content — a literal `&` (e.g. produced by `&` pre-expansion)
4888 // would create a bare ampersand in the output, which the spec
4889 // forbids. A literal `<` is forbidden in attribute values
4890 // (§ 3.1 WFC: No < in Attribute Values).
4891 let vb = value.as_bytes();
4892 let mut j = 0;
4893 while j < vb.len() {
4894 let b = vb[j];
4895 if b == b'<' {
4896 return Err(format!(
4897 "entity '&{name};' replacement text contains a literal `<` — \
4898 inclusion in an attribute value would violate XML 1.0 § 3.1 \
4899 WFC: No < in Attribute Values"
4900 ));
4901 }
4902 if b != b'&' { j += 1; continue; }
4903 let after = j + 1;
4904 let semi = vb[after..].iter().position(|&c| c == b';');
4905 let Some(off) = semi else {
4906 // Bare `&` not followed by a name — replacement text is
4907 // ill-formed when included. XML 1.0 § 4.1.
4908 return Err(format!(
4909 "entity '&{name};' replacement text contains a bare `&` — \
4910 inclusion would violate XML 1.0 § 4.1 [Reference]"
4911 ));
4912 };
4913 let body = &vb[after..after + off];
4914 if body.first() != Some(&b'#') {
4915 if let Ok(child) = std::str::from_utf8(body) {
4916 // SAFETY: `entities` keys outlive this call; recursing
4917 // with a borrowed `&'a str` into the same map is fine.
4918 let key: &'a str = entities
4919 .get_key_value(child)
4920 .map(|(k, _)| k.as_str())
4921 .unwrap_or(child);
4922 check_entity_chain(key, entities, chain, inside_external)?;
4923 }
4924 }
4925 j = after + off + 1;
4926 }
4927 chain.pop();
4928 Ok(())
4929}
4930
4931/// XML 1.0 § 4.5 [Entity Replacement Text]: expand character
4932/// references (`&#NN;` / `&#xNN;`) but leave general-entity
4933/// references unexpanded. This matches the spec's distinction
4934/// between LITERAL ENTITY VALUE (raw bytes between the quotes in
4935/// the `<!ENTITY>` declaration) and REPLACEMENT TEXT (what gets
4936/// substituted at expansion time).
4937///
4938/// Pre-expanding char refs at declaration time is what makes
4939/// `<!ENTITY e "&"> <doc>&e;</doc>` correctly fail: the
4940/// replacement text is a literal `&` byte; expanding `&e;` pushes
4941/// `&` into the parser stream; the parser sees a bare `&` not
4942/// Consume an XML 1.0 §4.3.1 text declaration if one is present
4943/// at the scanner's current position, and validate its structure.
4944///
4945/// ```text
4946/// TextDecl ::= '<?xml' VersionInfo? EncodingDecl S? '?>'
4947/// VersionInfo ::= S 'version' Eq ("'" VersionNum "'" | '"' VersionNum '"')
4948/// EncodingDecl ::= S 'encoding' Eq ('"' EncName '"' | "'" EncName "'")
4949/// ```
4950///
4951/// Distinct from the document-level XML declaration in two ways:
4952/// `version` is optional, and `standalone` is **forbidden**. We
4953/// enforce the latter by rejecting any pseudo-attribute name other
4954/// than `version` or `encoding`.
4955///
4956/// Returns `Ok(())` either way (no decl present, or one present and
4957/// well-formed). Returns `Err` when a decl is present but malformed
4958/// (e.g. carries `standalone=`, missing `?>`, encoding not a string).
4959///
4960/// Called right after [`push_entity_stream`] for *external* parsed
4961/// entities only — internal entity content has no text-decl and any
4962/// `<?xml...?>` at its start is a reserved-target PI (not-wf).
4963pub(crate) fn consume_text_decl_if_present(
4964 scan: &mut Scanner,
4965 is_outer_xml_11: bool,
4966) -> Result<()> {
4967 if !scan.starts_with(b"<?xml") { return Ok(()); }
4968 // The byte after `<?xml` must be whitespace so we don't
4969 // confuse a PI named e.g. `xml-stylesheet` for a text-decl.
4970 if !matches!(scan.peek_at(5), Some(b' ' | b'\t' | b'\r' | b'\n')) {
4971 return Ok(());
4972 }
4973 scan.skip_n(5); // past `<?xml`
4974 // Pseudo-attributes: optional `version=…` MUST come first,
4975 // then a required `encoding=…`. Anything else (`standalone`,
4976 // an unknown name, or version-after-encoding) is malformed.
4977 scan.skip_ws();
4978 let mut seen_version = false;
4979 let mut seen_encoding = false;
4980 while !scan.starts_with(b"?>") {
4981 if scan.peek().is_none() {
4982 return Err(scan.err("unterminated text declaration (expected '?>')"));
4983 }
4984 let key_start = scan.cur_pos();
4985 scan.skip_name()?;
4986 let key_end = scan.cur_pos();
4987 let key = scan.cur_slice(key_start, key_end).to_vec();
4988 match key.as_slice() {
4989 b"version" => {
4990 if seen_version {
4991 return Err(scan.err("text declaration has duplicate 'version' pseudo-attribute"));
4992 }
4993 if seen_encoding {
4994 return Err(scan.err(
4995 "text declaration must list 'version' before 'encoding' \
4996 (XML 1.0 § 4.3.1 [77])",
4997 ));
4998 }
4999 seen_version = true;
5000 scan.skip_ws();
5001 scan.expect(b'=')?;
5002 scan.skip_ws();
5003 let q = match scan.peek() {
5004 Some(b @ (b'"' | b'\'')) => { scan.advance(); b }
5005 _ => return Err(scan.err("expected quoted value in text declaration")),
5006 };
5007 let val_start = scan.cur_pos();
5008 while let Some(b) = scan.peek() {
5009 if b == q { break; }
5010 scan.advance();
5011 }
5012 let val_end = scan.cur_pos();
5013 let ver = scan.cur_slice(val_start, val_end).to_vec();
5014 scan.expect(q)?;
5015 // XML 1.0 §4.3.4 / XML 1.1 §4.3.4 — an external
5016 // parsed entity referenced from an XML 1.0 document
5017 // must itself be XML 1.0; from an XML 1.1 document
5018 // it may be either 1.0 or 1.1. `version="1.1"` in
5019 // an entity included by an XML 1.0 doc is not-wf.
5020 let entity_ver_ok = ver == b"1.0"
5021 || (is_outer_xml_11 && ver == b"1.1");
5022 if !entity_ver_ok {
5023 return Err(scan.err(format!(
5024 "text declaration version {:?} is not compatible with the \
5025 host document's version (XML §4.3.4)",
5026 String::from_utf8_lossy(&ver),
5027 )));
5028 }
5029 scan.skip_ws();
5030 }
5031 b"encoding" => {
5032 if seen_encoding {
5033 return Err(scan.err("text declaration has duplicate 'encoding' pseudo-attribute"));
5034 }
5035 seen_encoding = true;
5036 scan.skip_ws();
5037 scan.expect(b'=')?;
5038 scan.skip_ws();
5039 let q = match scan.peek() {
5040 Some(b @ (b'"' | b'\'')) => { scan.advance(); b }
5041 _ => return Err(scan.err("expected quoted value in text declaration")),
5042 };
5043 while let Some(b) = scan.peek() {
5044 if b == q { scan.advance(); break; }
5045 scan.advance();
5046 }
5047 scan.skip_ws();
5048 }
5049 other => return Err(scan.err(format!(
5050 "{:?} is not a valid pseudo-attribute in a text declaration \
5051 (XML 1.0 § 4.3.1 forbids anything besides version and encoding — \
5052 note standalone= is document-only, see § 2.9)",
5053 String::from_utf8_lossy(other),
5054 ))),
5055 }
5056 }
5057 scan.skip_n(2); // past `?>`
5058 if !seen_encoding {
5059 return Err(scan.err(
5060 "text declaration is missing the required encoding= pseudo-attribute \
5061 (XML 1.0 § 4.3.1)",
5062 ));
5063 }
5064 Ok(())
5065}
5066
5067/// Superseded by `XmlBytesReader::expand_entity_value`, which also
5068/// handles parameter-entity references per XML 1.0 § 4.5. Kept
5069/// here as a free function only briefly as the new path's
5070/// implementation reference; if you need the char-ref-only
5071/// behavior, call `expand_entity_value` with an empty
5072/// `parameter_entities` map.
5073#[allow(dead_code)]
5074fn expand_entity_replacement_text(bytes: &[u8]) -> std::result::Result<Vec<u8>, String> {
5075 let mut out = Vec::with_capacity(bytes.len());
5076 let mut i = 0;
5077 while i < bytes.len() {
5078 let b = bytes[i];
5079 if b != b'&' {
5080 out.push(b);
5081 i += 1;
5082 continue;
5083 }
5084 // `&` — char ref or named entity ref? Look at next byte.
5085 let after = i + 1;
5086 if after >= bytes.len() {
5087 return Err("entity value ends with `&`".to_string());
5088 }
5089 if bytes[after] != b'#' {
5090 // Named GE reference — keep literal per § 4.5.
5091 // Just emit through to the matching `;`.
5092 let semi = bytes[after..].iter().position(|&c| c == b';')
5093 .ok_or_else(|| "named entity reference in entity value missing `;`".to_string())?;
5094 out.extend_from_slice(&bytes[i..after + semi + 1]);
5095 i = after + semi + 1;
5096 continue;
5097 }
5098 // `&#…;` — character reference. Decode and emit the char.
5099 let body_start = after + 1;
5100 let semi = bytes[body_start..].iter().position(|&c| c == b';')
5101 .ok_or_else(|| "character reference missing `;`".to_string())?;
5102 let body = &bytes[body_start..body_start + semi];
5103 let cp: u32 = if body.first() == Some(&b'x') || body.first() == Some(&b'X') {
5104 std::str::from_utf8(&body[1..]).ok()
5105 .and_then(|h| u32::from_str_radix(h, 16).ok())
5106 .ok_or_else(|| format!(
5107 "invalid hex character reference '&#{}'", String::from_utf8_lossy(body)
5108 ))?
5109 } else {
5110 std::str::from_utf8(body).ok()
5111 .and_then(|d| d.parse::<u32>().ok())
5112 .ok_or_else(|| format!(
5113 "invalid decimal character reference '&#{}'", String::from_utf8_lossy(body)
5114 ))?
5115 };
5116 let ch = char::from_u32(cp).ok_or_else(|| format!(
5117 "character reference '&#{};' is not a valid Unicode scalar", cp
5118 ))?;
5119 let mut tmp = [0u8; 4];
5120 out.extend_from_slice(ch.encode_utf8(&mut tmp).as_bytes());
5121 i = body_start + semi + 1;
5122 }
5123 Ok(out)
5124}
5125
5126/// XML 1.0 § 2.8 [26] [VersionNum]: `'1.' [0-9]+`. No whitespace,
5127/// no leading sign, must start with the literal `1.`. XML 1.1 also
5128/// allows `'1.' [0-9]+` so the same shape works for both.
5129fn is_valid_version(v: &[u8]) -> bool {
5130 v.len() >= 3
5131 && &v[..2] == b"1."
5132 && v[2..].iter().all(|c| c.is_ascii_digit())
5133}
5134
5135/// XML 1.0 § 4.3.3 [81] [EncName]: `[A-Za-z] ([A-Za-z0-9._] | '-')*`.
5136/// First char must be a letter; remaining chars are letters, digits,
5137/// `.`, `_`, or `-`. Catches `" utf-8"` (leading space), `"a/b"`
5138/// (`/` not allowed), `"utf:8"` (`:` not allowed), etc.
5139fn is_valid_encname(v: &[u8]) -> bool {
5140 let Some(&first) = v.first() else { return false; };
5141 if !first.is_ascii_alphabetic() { return false; }
5142 v[1..].iter().all(|&c| {
5143 c.is_ascii_alphanumeric() || c == b'.' || c == b'_' || c == b'-'
5144 })
5145}
5146
5147/// Fast structural-only validator for attribute slices that contain
5148/// no `&` (no entity references) — the vast majority of real-world
5149/// documents. Skips entity-reference handling entirely; everything
5150/// else is the same as `validate_attrs_syntax`.
5151///
5152/// Caller has already checked there's no `<` in the slice. This
5153/// Read a single trailing occurrence indicator (`?`, `*`, `+`) from
5154/// the scanner, advancing past it. Returns `None` if the next byte
5155/// is something else (caller should treat as [`Occurrence::One`]).
5156fn read_occurrence<'src>(
5157 scan: &mut crate::scanner::Scanner<'src, 'static>,
5158) -> Option<crate::dtd::Occurrence> {
5159 use crate::dtd::Occurrence;
5160 match scan.peek() {
5161 Some(b'?') => { scan.advance(); Some(Occurrence::ZeroOrOne) }
5162 Some(b'*') => { scan.advance(); Some(Occurrence::ZeroOrMore) }
5163 Some(b'+') => { scan.advance(); Some(Occurrence::OneOrMore) }
5164 _ => None,
5165 }
5166}
5167
5168
5169/// fast path uses memchr to jump to the closing quote of each
5170/// value rather than walking byte-by-byte, which dominated the
5171/// per-attribute cost on attribute-heavy fixtures.
5172fn validate_attrs_structure_fast(
5173 bytes: &[u8],
5174 opts: &ParseOptions,
5175) -> std::result::Result<(), String> {
5176 use crate::charsets::{ASCII_XML_NAME, NS};
5177 let mut i = 0;
5178 let n = bytes.len();
5179 let mut seen_inline: [(u32, u32); 8] = [(0, 0); 8];
5180 let mut seen_inline_len: usize = 0;
5181 let mut seen_overflow: Vec<(u32, u32)> = Vec::new();
5182 // Adversarial inputs may have thousands of attributes on a single
5183 // element. Once the overflow Vec gets large, swap to a hash set so
5184 // duplicate detection stays linear in the attribute count instead
5185 // of quadratic. Threshold picked so the common 9–31-attr case still
5186 // takes the cache-friendly linear path.
5187 let mut seen_set: Option<FxHashSet<&[u8]>> = None;
5188 const SEEN_PROMOTE_THRESHOLD: usize = 32;
5189
5190 fn is_ws(b: u8) -> bool { matches!(b, b' ' | b'\t' | b'\n' | b'\r') }
5191
5192 let mut first_attr = true;
5193 while i < n {
5194 // Inter-attribute whitespace is required (except before
5195 // the first attribute) — XML 1.0 § 3.1 [40].
5196 let ws_start = i;
5197 while i < n && is_ws(bytes[i]) { i += 1; }
5198 if i >= n { break; }
5199 if !first_attr && i == ws_start {
5200 return Err(
5201 "expected whitespace between attributes (XML 1.0 § 3.1 [40] [STag])".to_string()
5202 );
5203 }
5204 first_attr = false;
5205
5206 // Name: validate the leading char then skip to next `=`,
5207 // whitespace, or end via memchr.
5208 let name_start = i;
5209 if !opts.skip_name_validation {
5210 let b = bytes[i];
5211 if b < 0x80 && ASCII_XML_NAME[b as usize] & NS == 0 {
5212 return Err(format!(
5213 "invalid attribute name-start character {:?}",
5214 b as char
5215 ));
5216 }
5217 }
5218 let stop = memchr3(b'=', b' ', b'\t', &bytes[i..n])
5219 .map(|o| i + o)
5220 .unwrap_or(n);
5221 // Manual sweep for `\n` / `\r` (rare in attr-name positions).
5222 let stop = bytes[i..stop].iter().position(|&c| c == b'\n' || c == b'\r')
5223 .map(|o| i + o)
5224 .unwrap_or(stop);
5225 i = stop;
5226 let name_end = i;
5227 if name_end == name_start {
5228 return Err("expected attribute name".to_string());
5229 }
5230
5231 // Eq: optional whitespace, `=`, optional whitespace.
5232 while i < n && is_ws(bytes[i]) { i += 1; }
5233 if i >= n || bytes[i] != b'=' {
5234 return Err("expected '=' after attribute name".to_string());
5235 }
5236 i += 1;
5237 while i < n && is_ws(bytes[i]) { i += 1; }
5238
5239 // Quoted value: jump straight to the closing quote. `<`
5240 // can't appear (caller checked); `&` can't appear (that's
5241 // the precondition for being on this fast path).
5242 if i >= n {
5243 return Err("expected quoted attribute value".to_string());
5244 }
5245 let q = bytes[i];
5246 if q != b'"' && q != b'\'' {
5247 return Err(format!(
5248 "attribute value must be quoted (got '{}'); XML 1.0 § 3.1 [41] [AttValue]",
5249 q as char
5250 ));
5251 }
5252 i += 1;
5253 let value_start = i;
5254 let val_off = memchr(q, &bytes[value_start..n])
5255 .ok_or_else(|| "unterminated attribute value".to_string())?;
5256 i = value_start + val_off + 1;
5257
5258 // Duplicate-name check (§ 3.1 WFC: Unique Att Spec).
5259 let new_name = &bytes[name_start..name_end];
5260 for &(s, e) in &seen_inline[..seen_inline_len] {
5261 if &bytes[s as usize..e as usize] == new_name {
5262 return Err(format!(
5263 "duplicate attribute '{}' in start tag (XML 1.0 § 3.1 WFC: Unique Att Spec)",
5264 String::from_utf8_lossy(new_name)
5265 ));
5266 }
5267 }
5268 if let Some(set) = seen_set.as_mut() {
5269 if !set.insert(new_name) {
5270 return Err(format!(
5271 "duplicate attribute '{}' in start tag (XML 1.0 § 3.1 WFC: Unique Att Spec)",
5272 String::from_utf8_lossy(new_name)
5273 ));
5274 }
5275 } else {
5276 for &(s, e) in &seen_overflow {
5277 if &bytes[s as usize..e as usize] == new_name {
5278 return Err(format!(
5279 "duplicate attribute '{}' in start tag (XML 1.0 § 3.1 WFC: Unique Att Spec)",
5280 String::from_utf8_lossy(new_name)
5281 ));
5282 }
5283 }
5284 if seen_inline_len < seen_inline.len() {
5285 seen_inline[seen_inline_len] = (name_start as u32, name_end as u32);
5286 seen_inline_len += 1;
5287 } else {
5288 seen_overflow.push((name_start as u32, name_end as u32));
5289 if seen_overflow.len() >= SEEN_PROMOTE_THRESHOLD {
5290 let mut set: FxHashSet<&[u8]> = FxHashSet::default();
5291 set.reserve(seen_overflow.len() * 2);
5292 for &(s, e) in &seen_overflow {
5293 set.insert(&bytes[s as usize..e as usize]);
5294 }
5295 seen_set = Some(set);
5296 }
5297 }
5298 }
5299 }
5300 Ok(())
5301}
5302
5303/// Eagerly validate the syntax of one start tag's attribute slice.
5304/// Called from `read_start_element` so well-formedness errors fire
5305/// even when the application never iterates `BytesAttrs`. This is
5306/// a syntactic check only — no entity expansion, no decoding, no
5307/// allocation; just a single pass over the bytes verifying:
5308///
5309/// - each attr starts with a valid Name (XML 1.0 § 2.3 [4–5])
5310/// - the next char after the name is `=` (modulo whitespace)
5311/// - the value is quoted with `"` or `'` (§ 3.1 [41])
5312/// - the value doesn't contain a literal `<` (§ 3.1 WFC)
5313/// - any `&` inside the value is followed by a valid
5314/// `Name;` (entity ref) or `#[0-9]+;` / `#x[0-9a-fA-F]+;`
5315/// (char ref) (§ 4.1 WFC)
5316/// - no attribute name appears more than once (§ 3.1 WFC)
5317///
5318/// Returns `Err(message)` describing the violation; the caller
5319/// wraps it in a `Scanner::err` with location info.
5320fn validate_attrs_syntax(
5321 bytes: &[u8],
5322 opts: &ParseOptions,
5323 entities: &HashMap<String, EntityDecl>,
5324 inside_external: bool,
5325) -> std::result::Result<(), String> {
5326 use crate::charsets::{ASCII_XML_NAME, NS, NC};
5327 let mut i = 0;
5328 let n = bytes.len();
5329 // Most start tags have < 8 attrs. Use a stack array up to that
5330 // cap to avoid the per-element heap allocation that dominates
5331 // attribute-heavy fixtures (bargains_he_5, OSM, gazali — every
5332 // element has 5–10 attrs and triggers a Vec allocation otherwise).
5333 // Fall back to a Vec for the rare element with > 8 attrs.
5334 let mut seen_inline: [(u32, u32); 8] = [(0, 0); 8];
5335 let mut seen_inline_len: usize = 0;
5336 let mut seen_overflow: Vec<(u32, u32)> = Vec::new();
5337 // Adversarial inputs may have thousands of attributes on a single
5338 // element. Once the overflow Vec gets large, swap to a hash set so
5339 // duplicate detection stays linear in the attribute count instead
5340 // of quadratic. Threshold picked so the common 9–31-attr case still
5341 // takes the cache-friendly linear path.
5342 let mut seen_set: Option<FxHashSet<&[u8]>> = None;
5343 const SEEN_PROMOTE_THRESHOLD: usize = 32;
5344
5345 fn is_ws(b: u8) -> bool { matches!(b, b' ' | b'\t' | b'\n' | b'\r') }
5346
5347 let mut first_attr = true;
5348 while i < n {
5349 // XML 1.0 § 3.1 [40] [STag]: each attribute MUST be preceded
5350 // by whitespace. The leading run before the first attr can
5351 // be empty (the tag name is followed directly by `(S Attribute)*`
5352 // — but only if there are no attributes). Since we're past
5353 // the tag name when we get here, any non-leading attribute
5354 // requires at least one whitespace byte before it.
5355 let ws_start = i;
5356 while i < n && is_ws(bytes[i]) { i += 1; }
5357 if i >= n { break; }
5358 if !first_attr && i == ws_start {
5359 return Err(
5360 "expected whitespace between attributes (XML 1.0 § 3.1 [40] [STag])".to_string()
5361 );
5362 }
5363 first_attr = false;
5364
5365 // ── Name ────────────────────────────────────────────
5366 // Validate the name-start character (one ASCII table
5367 // lookup), then skip remaining name chars cheaply by
5368 // memchring for the first byte that's NOT a name char —
5369 // i.e. `=`, whitespace, or end of input. This avoids the
5370 // per-byte ASCII-table check that dominated start-tag time
5371 // for attribute-heavy fixtures (bargains_he_5, gazali, osm).
5372 let name_start = i;
5373 if !opts.skip_name_validation {
5374 let b = bytes[i];
5375 if b < 0x80 && ASCII_XML_NAME[b as usize] & NS == 0 {
5376 return Err(format!(
5377 "invalid attribute name-start character {:?}",
5378 b as char
5379 ));
5380 }
5381 // Non-ASCII name-start chars are accepted conservatively
5382 // (the full Unicode NameStart table lives in Scanner;
5383 // duplicating it here would be wasteful and the lazy
5384 // iterator's full validation will fire if the user
5385 // reads attrs).
5386 }
5387 // Skip past the name to the next `=`, whitespace, or `/`.
5388 // Single SIMD scan beats the per-byte name-char loop. Any
5389 // weird byte inside the name (e.g. `!`, `?`) is caught by
5390 // the lazy attribute iterator's full validation if/when the
5391 // caller actually reads the attribute.
5392 let stop = memchr3(b'=', b' ', b'\t', &bytes[i..n])
5393 .map(|o| i + o)
5394 .unwrap_or(n);
5395 // `\n` and `\r` also count as whitespace per XML 1.0 § 2.3
5396 // [3] [S], but those are rare in attribute-name positions in
5397 // real-world documents — fall back to a manual sweep if we
5398 // hit one before `=`.
5399 let stop = {
5400 let s = bytes[i..stop].iter().position(|&c| c == b'\n' || c == b'\r')
5401 .map(|o| i + o)
5402 .unwrap_or(stop);
5403 s
5404 };
5405 i = stop;
5406 let name_end = i;
5407 if name_end == name_start {
5408 return Err("expected attribute name".to_string());
5409 }
5410
5411 // ── Eq ──────────────────────────────────────────────
5412 while i < n && is_ws(bytes[i]) { i += 1; }
5413 if i >= n || bytes[i] != b'=' {
5414 return Err("expected '=' after attribute name".to_string());
5415 }
5416 i += 1;
5417 while i < n && is_ws(bytes[i]) { i += 1; }
5418
5419 // ── AttValue ────────────────────────────────────────
5420 if i >= n {
5421 return Err("expected quoted attribute value".to_string());
5422 }
5423 let q = bytes[i];
5424 if q != b'"' && q != b'\'' {
5425 return Err(format!(
5426 "attribute value must be quoted (got '{}'); XML 1.0 § 3.1 [41] [AttValue]",
5427 q as char
5428 ));
5429 }
5430 i += 1;
5431 // Scan to closing quote checking for forbidden chars.
5432 // Use memchr3 to SIMD-jump to the next interesting byte
5433 // (`<` / `&` / quote) rather than walking byte-by-byte —
5434 // gives the same throughput as the lazy iterator's value
5435 // scan for clean values that contain none of the three.
5436 loop {
5437 let tail = &bytes[i..n];
5438 let off = match memchr3(b'<', b'&', q, tail) {
5439 Some(o) => o,
5440 None => return Err("unterminated attribute value".to_string()),
5441 };
5442 i += off;
5443 let b = bytes[i];
5444 if b == q { i += 1; break; }
5445 if b == b'<' {
5446 return Err(
5447 "'<' not allowed in attribute value (XML 1.0 § 3.1 WFC: No < in Attribute Values)".to_string()
5448 );
5449 }
5450 if b == b'&' {
5451 // Must be either `&Name;` or `&#NNN;` / `&#xNN;`.
5452 let after = i + 1;
5453 if after >= n {
5454 return Err("unterminated entity reference in attribute value".to_string());
5455 }
5456 let semi = bytes[after..].iter().position(|&c| c == b';' || c == b'<' || c == q);
5457 let semi_off = match semi {
5458 Some(off) if bytes[after + off] == b';' => off,
5459 _ => return Err(
5460 "bare '&' in attribute value (must be an entity / character reference; XML 1.0 § 4.1)".to_string()
5461 ),
5462 };
5463 let body = &bytes[after..after + semi_off];
5464 if body.is_empty() {
5465 return Err("empty reference '&;' in attribute value".to_string());
5466 }
5467 if body[0] == b'#' {
5468 // Numeric character reference.
5469 let digits: &[u8] = if body.len() >= 2 && (body[1] == b'x' || body[1] == b'X') {
5470 &body[2..]
5471 } else { &body[1..] };
5472 if digits.is_empty() {
5473 return Err("empty numeric character reference".to_string());
5474 }
5475 let valid = if body.len() >= 2 && (body[1] == b'x' || body[1] == b'X') {
5476 digits.iter().all(|&c| c.is_ascii_hexdigit())
5477 } else {
5478 digits.iter().all(|&c| c.is_ascii_digit())
5479 };
5480 if !valid {
5481 return Err(format!("invalid character reference '&{};'",
5482 String::from_utf8_lossy(body)));
5483 }
5484 } else {
5485 // Named entity reference.
5486 if !opts.skip_name_validation {
5487 let b0 = body[0];
5488 if b0 < 0x80 && ASCII_XML_NAME[b0 as usize] & NS == 0 {
5489 return Err(format!(
5490 "invalid entity-reference name '&{};' in attribute value",
5491 String::from_utf8_lossy(body)
5492 ));
5493 }
5494 for &b in &body[1..] {
5495 if b < 0x80 && ASCII_XML_NAME[b as usize] & (NS | NC) == 0 {
5496 return Err(format!(
5497 "invalid entity-reference name '&{};' in attribute value",
5498 String::from_utf8_lossy(body)
5499 ));
5500 }
5501 }
5502 }
5503 // XML 1.0 § 4.1 WFC: Entity Declared + § 4.1 WFC:
5504 // No Recursion. Any named entity must be declared,
5505 // and recursion through nested entity references
5506 // must not form a cycle. Walk the chain and check
5507 // both.
5508 let name = std::str::from_utf8(body).unwrap_or("?");
5509 let predef = matches!(name, "amp" | "lt" | "gt" | "quot" | "apos");
5510 if !predef {
5511 // XML 1.0 § 4.4.4 [Forbidden]: external
5512 // entity references inside an attribute
5513 // value are a fatal error (WFC: No External
5514 // Entity References). Catches `<doc a="&extE;">`.
5515 // This rule is independent of libxml2_compat —
5516 // libxml2 itself enforces it.
5517 if entities.get(name).is_some_and(|d| d.kind.is_external_value()) {
5518 return Err(format!(
5519 "external entity '&{name};' is not allowed in an attribute value \
5520 (XML 1.0 § 4.4.4 WFC: No External Entity References)"
5521 ));
5522 }
5523 // The cycle / replacement-text check requires
5524 // a DTD entity table. Documents with no DTD
5525 // (the common case) reach here only with
5526 // predefined entities, which are handled
5527 // above; any other name is an error. Skip
5528 // the recursive walk when the table is empty.
5529 if !entities.is_empty() {
5530 let mut chain: Vec<&str> = Vec::new();
5531 check_entity_chain(name, entities, &mut chain, inside_external)?;
5532 } else if !inside_external {
5533 return Err(format!(
5534 "undefined entity '&{name};' (XML 1.0 § 4.1 WFC: Entity Declared)"
5535 ));
5536 }
5537 }
5538 }
5539 i = after + semi_off + 1;
5540 continue;
5541 }
5542 i += 1;
5543 }
5544
5545 // ── duplicate-name check (§ 3.1 WFC: Unique Att Spec) ─
5546 // Walk the inline-array prefix first, then the overflow Vec
5547 // (empty in the common case). No heap traffic for tags
5548 // with <= 8 attributes. Past SEEN_PROMOTE_THRESHOLD overflow
5549 // entries we flip to a hash set to avoid quadratic blowup on
5550 // adversarial inputs.
5551 let new_name = &bytes[name_start..name_end];
5552 let ns_u32 = name_start as u32;
5553 let ne_u32 = name_end as u32;
5554 for &(s, e) in &seen_inline[..seen_inline_len] {
5555 if &bytes[s as usize..e as usize] == new_name {
5556 return Err(format!(
5557 "duplicate attribute '{}' in start tag (XML 1.0 § 3.1 WFC: Unique Att Spec)",
5558 String::from_utf8_lossy(new_name)
5559 ));
5560 }
5561 }
5562 if let Some(set) = seen_set.as_mut() {
5563 if !set.insert(new_name) {
5564 return Err(format!(
5565 "duplicate attribute '{}' in start tag (XML 1.0 § 3.1 WFC: Unique Att Spec)",
5566 String::from_utf8_lossy(new_name)
5567 ));
5568 }
5569 } else {
5570 for &(s, e) in &seen_overflow {
5571 if &bytes[s as usize..e as usize] == new_name {
5572 return Err(format!(
5573 "duplicate attribute '{}' in start tag (XML 1.0 § 3.1 WFC: Unique Att Spec)",
5574 String::from_utf8_lossy(new_name)
5575 ));
5576 }
5577 }
5578 if seen_inline_len < seen_inline.len() {
5579 seen_inline[seen_inline_len] = (ns_u32, ne_u32);
5580 seen_inline_len += 1;
5581 } else {
5582 seen_overflow.push((ns_u32, ne_u32));
5583 if seen_overflow.len() >= SEEN_PROMOTE_THRESHOLD {
5584 let mut set: FxHashSet<&[u8]> = FxHashSet::default();
5585 set.reserve(seen_overflow.len() * 2);
5586 for &(s, e) in &seen_overflow {
5587 set.insert(&bytes[s as usize..e as usize]);
5588 }
5589 seen_set = Some(set);
5590 }
5591 }
5592 }
5593 }
5594
5595 Ok(())
5596}
5597
5598/// XML §3.3.3 attribute-value normalization (CDATA default).
5599///
5600/// `\t`, `\n`, `\r` are rewritten to a single `#x20` space; in XML 1.1
5601/// the same applies to NEL (`0xC2 0x85`) and LS (`0xE2 0x80 0xA8`).
5602/// All other bytes are copied through verbatim — entity references
5603/// and char references have already been resolved by the caller.
5604///
5605/// This is the *CDATA-default* form: it does not collapse runs or
5606/// trim leading/trailing spaces. The non-CDATA pass (used by DTD-
5607/// or schema-typed attributes) layers on top of this in
5608/// `dtd_normalize_attr_value` and friends.
5609fn append_attr_normalized(src: &[u8], dst: &mut Vec<u8>, is_xml_11: bool) {
5610 let mut i = 0;
5611 while i < src.len() {
5612 let rest = &src[i..];
5613 let off = if is_xml_11 {
5614 // Five candidates need to be located in one pass. memchr3
5615 // is a tight SIMD loop; a separate memchr2 for the 1.1
5616 // EOL leads picks up NEL / LS, and we take whichever match
5617 // comes first in the rest slice.
5618 let a = memchr3(b'\t', b'\n', b'\r', rest);
5619 let b = memchr::memchr2(0xC2, 0xE2, rest);
5620 match (a, b) {
5621 (Some(x), Some(y)) => Some(x.min(y)),
5622 (a, b) => a.or(b),
5623 }
5624 } else {
5625 memchr3(b'\t', b'\n', b'\r', rest)
5626 };
5627 let stop = match off {
5628 Some(o) => i + o,
5629 None => src.len(),
5630 };
5631 if stop > i {
5632 dst.extend_from_slice(&src[i..stop]);
5633 }
5634 if stop >= src.len() { break; }
5635 let b = src[stop];
5636 let consumed = match b {
5637 b'\t' | b'\n' | b'\r' => {
5638 dst.push(b' ');
5639 1
5640 }
5641 0xC2 if is_xml_11 && src.get(stop + 1) == Some(&0x85) => {
5642 dst.push(b' ');
5643 2
5644 }
5645 0xE2 if is_xml_11
5646 && src.get(stop + 1) == Some(&0x80)
5647 && src.get(stop + 2) == Some(&0xA8) =>
5648 {
5649 dst.push(b' ');
5650 3
5651 }
5652 _ => {
5653 // 0xC2 / 0xE2 lead that didn't continue into NEL/LS —
5654 // emit the byte verbatim.
5655 dst.push(b);
5656 1
5657 }
5658 };
5659 i = stop + consumed;
5660 }
5661}
5662
5663/// Scan `bytes` for the first byte that triggers §3.3.3 attribute-
5664/// value normalization: `\t`, `\n`, `\r`, plus (XML 1.1) the UTF-8
5665/// lead of NEL or LS. Returns `None` when the attribute is already
5666/// in normalized form — the lazy hot-path zero-copy check.
5667#[inline(always)]
5668fn first_attr_norm_byte(bytes: &[u8], is_xml_11: bool) -> Option<usize> {
5669 let in_1_0 = memchr3(b'\t', b'\n', b'\r', bytes);
5670 if !is_xml_11 || in_1_0.is_some() {
5671 return in_1_0;
5672 }
5673 memchr::memchr2(0xC2, 0xE2, bytes)
5674}
5675
5676/// Apply §3.3.3 attribute-value normalization lazily to `raw`.
5677///
5678/// Returns `raw` unchanged when the value contains none of the
5679/// normalization-significant bytes — the common case for hand-
5680/// authored documents where attributes are written with literal
5681/// spaces only.
5682///
5683/// This is the **fast-path** wrapper, used when the attribute value
5684/// contained no `&` reference — every byte in `raw` is literal
5685/// source content and so §3.3.3 applies uniformly. The slow path
5686/// (entity / char references present) builds its buffer incrementally
5687/// with [`append_attr_segment`] so that *character* references can
5688/// inject literal `\t` / `\n` / `\r` without those bytes being
5689/// rewritten away.
5690#[inline(always)]
5691fn maybe_normalize_attr_value<'a>(raw: Cow<'a, [u8]>, is_xml_11: bool) -> Cow<'a, [u8]> {
5692 if first_attr_norm_byte(&raw, is_xml_11).is_none() {
5693 return raw;
5694 }
5695 let mut out = Vec::with_capacity(raw.len());
5696 append_attr_normalized(&raw, &mut out, is_xml_11);
5697 Cow::Owned(out)
5698}
5699
5700/// Copy `scan.cur_bytes()[start..end]` into `buf`, applying §3.3.3
5701/// CDATA-default normalization to *only the source-borrowed bytes*.
5702///
5703/// The slow path of `scan_att_value_cow` builds the attribute value
5704/// by alternating segments of source bytes with the results of
5705/// character-reference / entity-reference expansion. XML §3.3.3
5706/// rewrites literal `\t` / `\n` / `\r` (and in XML 1.1 NEL / LS) to
5707/// space, but **does not** rewrite the same characters when they
5708/// arrive via a character reference — that's the whole reason
5709/// authors write `
` to inject a literal LF. Confining the
5710/// rewrite to source segments preserves that contract.
5711#[inline(always)]
5712fn append_attr_segment(
5713 scan: &Scanner<'_, '_>,
5714 start: usize,
5715 end: usize,
5716 buf: &mut Vec<u8>,
5717 is_xml_11: bool,
5718) {
5719 let bytes = &scan.cur_bytes()[start..end];
5720 if first_attr_norm_byte(bytes, is_xml_11).is_none() {
5721 buf.extend_from_slice(bytes);
5722 } else {
5723 append_attr_normalized(bytes, buf, is_xml_11);
5724 }
5725}
5726
5727/// One-shot pre-scan over the document source to decide whether any
5728/// §2.11 EOL rewriting is needed at all. Returns `true` when the
5729/// source contains at least one `\r`, NEL (`0xC2 0x85`), or LS
5730/// (`0xE2 0x80 0xA8`). The cheap memchr3 lead-byte sweep is paired
5731/// with a lookahead so that ordinary 2-/3-byte UTF-8 sequences whose
5732/// lead happens to be `0xC2` or `0xE2` don't pin the per-segment
5733/// scan flag — important for ASCII-with-accents documents where
5734/// `0xC2 …` shows up on every Latin-1-supplement character.
5735fn precompute_source_has_eol(src: &[u8]) -> bool {
5736 let mut i = 0;
5737 while i < src.len() {
5738 let rest = &src[i..];
5739 let off = match memchr3(b'\r', 0xC2, 0xE2, rest) {
5740 Some(o) => o,
5741 None => return false,
5742 };
5743 let pos = i + off;
5744 match src[pos] {
5745 b'\r' => return true,
5746 0xC2 if src.get(pos + 1) == Some(&0x85) => return true,
5747 0xE2 if src.get(pos + 1) == Some(&0x80)
5748 && src.get(pos + 2) == Some(&0xA8) =>
5749 {
5750 return true;
5751 }
5752 _ => i = pos + 1,
5753 }
5754 }
5755 false
5756}
5757
5758/// Scan `bytes` for the first byte that *could* participate in an
5759/// XML §2.11 end-of-line normalization rewrite.
5760///
5761/// Under XML 1.0 the only trigger is `#xD` (`\r`). Under XML 1.1
5762/// the trigger set widens to include the UTF-8 lead bytes of NEL
5763/// (`U+0085` = `0xC2 0x85`) and LS (`U+2028` = `0xE2 0x80 0xA8`).
5764///
5765/// Returns `None` when `bytes` can be emitted verbatim — the
5766/// common case in modern documents — letting the text hot-path
5767/// stay zero-copy.
5768#[inline(always)]
5769pub(crate) fn first_eol_byte(bytes: &[u8], is_xml_11: bool) -> Option<usize> {
5770 if is_xml_11 {
5771 memchr3(b'\r', 0xC2, 0xE2, bytes)
5772 } else {
5773 memchr(b'\r', bytes)
5774 }
5775}
5776
5777/// Append `src` to `dst`, applying XML §2.11 end-of-line normalization:
5778///
5779/// * `\r\n` → `\n`
5780/// * `\r` → `\n`
5781/// * (XML 1.1 only) `\r\x85` → `\n`,
5782/// `\xc2\x85` (NEL) → `\n`,
5783/// `\xe2\x80\xa8` (LS) → `\n`
5784///
5785/// Every other byte is appended verbatim. Callers should consult
5786/// [`first_eol_byte`] first; when it returns `None`, this function
5787/// is a more-expensive `extend_from_slice` and should be skipped.
5788///
5789/// Internally walks `src` in runs: a SIMD memchr finds the next
5790/// EOL-candidate byte, the bytes before it are bulk-copied with
5791/// `extend_from_slice` (memcpy under the hood), then a single
5792/// EOL sequence is processed. On documents with CRLF line endings
5793/// this drops the per-byte loop overhead of the naive version —
5794/// the dominant cost becomes the rewrite ratio itself.
5795pub(crate) fn append_eol_normalized(src: &[u8], dst: &mut Vec<u8>, is_xml_11: bool) {
5796 let mut i = 0;
5797 while i < src.len() {
5798 let rest = &src[i..];
5799 let stop = match first_eol_byte(rest, is_xml_11) {
5800 Some(off) => i + off,
5801 None => src.len(),
5802 };
5803 if stop > i {
5804 dst.extend_from_slice(&src[i..stop]);
5805 }
5806 if stop >= src.len() { break; }
5807 let b = src[stop];
5808 let consumed = if b == b'\r' {
5809 dst.push(b'\n');
5810 if src.get(stop + 1) == Some(&b'\n') {
5811 2
5812 } else if is_xml_11
5813 && src.get(stop + 1) == Some(&0xC2)
5814 && src.get(stop + 2) == Some(&0x85)
5815 {
5816 3
5817 } else {
5818 1
5819 }
5820 } else if is_xml_11
5821 && b == 0xC2
5822 && src.get(stop + 1) == Some(&0x85)
5823 {
5824 dst.push(b'\n');
5825 2
5826 } else if is_xml_11
5827 && b == 0xE2
5828 && src.get(stop + 1) == Some(&0x80)
5829 && src.get(stop + 2) == Some(&0xA8)
5830 {
5831 dst.push(b'\n');
5832 3
5833 } else {
5834 // 0xC2 / 0xE2 lead that didn't continue into NEL/LS —
5835 // emit the candidate byte verbatim and advance.
5836 dst.push(b);
5837 1
5838 };
5839 i = stop + consumed;
5840 }
5841}
5842
5843/// Copy `scan.cur_bytes()[start..end]` into `buf`, applying XML §2.11
5844/// end-of-line normalization on the fly. Falls back to a plain
5845/// `extend_from_slice` when the segment contains no normalization-
5846/// significant bytes (the common case).
5847///
5848/// `gate` is the reader's `source_has_eol_candidate` flag — when
5849/// false we know the document carries no `\r`, NEL, or LS anywhere
5850/// in source and can skip the per-segment SIMD scan altogether.
5851#[inline(always)]
5852pub(crate) fn append_text_segment(
5853 scan: &Scanner<'_, '_>,
5854 start: usize,
5855 end: usize,
5856 buf: &mut Vec<u8>,
5857 is_xml_11: bool,
5858 gate: bool,
5859) {
5860 let bytes = &scan.cur_bytes()[start..end];
5861 if !gate {
5862 buf.extend_from_slice(bytes);
5863 return;
5864 }
5865 if first_eol_byte(bytes, is_xml_11).is_none() {
5866 buf.extend_from_slice(bytes);
5867 } else {
5868 append_eol_normalized(bytes, buf, is_xml_11);
5869 }
5870}
5871
5872/// Apply §2.11 end-of-line normalization to `raw` lazily: return the
5873/// input untouched when it contains no normalization-significant
5874/// byte, otherwise allocate an owned Vec and rewrite.
5875///
5876/// This is the hot-path wrapper used by the text and CDATA emitters
5877/// — modern documents take the borrowed path, paying only a SIMD
5878/// memchr scan over the segment. Pass the reader's
5879/// `source_has_eol_candidate` flag as `gate`: when `false` the
5880/// per-segment scan is skipped (no EOL bytes are reachable in the
5881/// document).
5882#[inline(always)]
5883pub(crate) fn maybe_normalize_eol<'a>(
5884 raw: Cow<'a, [u8]>,
5885 is_xml_11: bool,
5886 gate: bool,
5887) -> Cow<'a, [u8]> {
5888 if !gate {
5889 return raw;
5890 }
5891 if first_eol_byte(&raw, is_xml_11).is_none() {
5892 return raw;
5893 }
5894 let mut out = Vec::with_capacity(raw.len());
5895 append_eol_normalized(&raw, &mut out, is_xml_11);
5896 Cow::Owned(out)
5897}
5898
5899/// Expand the five XML predefined entity references (`&`, `<`,
5900/// `>`, `"`, `'`) and numeric character references (`&#NN;`,
5901/// `&#xNN;`) inside `s`. Intended for callers using
5902/// [`ParseOptions::skip_entity_expansion`] who want to decode a specific
5903/// text payload on demand.
5904///
5905/// Returns `Cow::Borrowed(s)` when `s` contains no `&` — i.e. the
5906/// no-entity case is zero-copy.
5907///
5908/// **General entities** declared in a DTD (`<!ENTITY foo "...">`) are not
5909/// expanded here; the helper has no access to the document's entity table.
5910/// If you need DTD-defined entity expansion, don't enable
5911/// `skip_entity_expansion` in the first place.
5912/// Byte-output sibling of [`reader::unescape`](crate::reader::unescape).
5913/// Expands the five XML predefined entity references and numeric character
5914/// references inside `bytes` (which must be valid UTF-8), returning the
5915/// decoded form. Returns `Cow::Borrowed(bytes)` when no `&` appears.
5916///
5917/// Intended for callers using
5918/// [`ParseOptions::skip_entity_expansion`](crate::ParseOptions::skip_entity_expansion)
5919/// with `XmlBytesReader` who want to decode a specific text payload on
5920/// demand. General entities declared in a DTD are *not* expanded — the
5921/// helper has no access to the document's entity table.
5922pub fn unescape_bytes(bytes: &[u8]) -> Cow<'_, [u8]> {
5923 if memchr(b'&', bytes).is_none() {
5924 return Cow::Borrowed(bytes);
5925 }
5926 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
5927 let mut i = 0;
5928 while i < bytes.len() {
5929 if bytes[i] != b'&' {
5930 out.push(bytes[i]);
5931 i += 1;
5932 continue;
5933 }
5934 let rest = &bytes[i + 1..];
5935 let semi = match memchr(b';', rest) {
5936 Some(n) if n <= 16 => n,
5937 _ => { out.push(b'&'); i += 1; continue; }
5938 };
5939 let name = &rest[..semi];
5940 match name {
5941 b"amp" => out.push(b'&'),
5942 b"lt" => out.push(b'<'),
5943 b"gt" => out.push(b'>'),
5944 b"quot" => out.push(b'"'),
5945 b"apos" => out.push(b'\''),
5946 _ if name.starts_with(b"#") => {
5947 let cp: Option<u32> = if name.len() >= 2 && (name[1] == b'x' || name[1] == b'X') {
5948 std::str::from_utf8(&name[2..]).ok()
5949 .and_then(|h| u32::from_str_radix(h, 16).ok())
5950 } else {
5951 std::str::from_utf8(&name[1..]).ok()
5952 .and_then(|d| d.parse::<u32>().ok())
5953 };
5954 match cp.and_then(char::from_u32) {
5955 Some(c) => {
5956 let mut tmp = [0u8; 4];
5957 out.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes());
5958 }
5959 None => {
5960 // Leave invalid char-ref as literal so a downstream
5961 // strict parser can flag it.
5962 out.push(b'&');
5963 out.extend_from_slice(name);
5964 out.push(b';');
5965 }
5966 }
5967 }
5968 _ => {
5969 // Unknown entity — leave verbatim.
5970 out.push(b'&');
5971 out.extend_from_slice(name);
5972 out.push(b';');
5973 }
5974 }
5975 i += 1 + semi + 1;
5976 }
5977 Cow::Owned(out)
5978}
5979
5980/// Byte-output entity-reference expansion used by `XmlBytesReader`'s slow-path.
5981/// Writes the expanded entity content into a `Vec<u8>` buffer (as UTF-8 bytes).
5982pub(crate) fn expand_reference_bytes(
5983 scan: &mut Scanner<'_, '_>,
5984 buf: &mut Vec<u8>,
5985 entities: &HashMap<String, EntityDecl>,
5986 used: &mut u64,
5987 budget: u64,
5988 element_depth: u32,
5989 // Recovery sink — callers in recover mode pass `Some(&mut vec)`;
5990 // strict-mode callers pass `None`. When `Some`, we log
5991 // recoverable errors to it and continue with a best-effort
5992 // representation of the malformed reference (literal `&name;`
5993 // bytes left in `buf`).
5994 recovered: Option<&mut Vec<crate::error::XmlError>>,
5995 // XML 1.0 errata E13: when the internal DTD subset contained at
5996 // least one parameter-entity reference, an undeclared general
5997 // entity is a validity error, not a well-formedness error.
5998 // Caller passes `true` to silence the WF-level rejection so the
5999 // doc still parses; the missing decl gets dropped on the floor
6000 // (the reference expands to nothing).
6001 pe_relax_undefined: bool,
6002 // XML 1.0 § 2.9 + § 4.1 WFC: Entity Declared. `true` when the
6003 // document declared `standalone='yes'`; references to entities
6004 // whose declaration came from the external subset are then
6005 // not-well-formed.
6006 standalone_yes: bool,
6007 // True when the host document declared `version="1.1"`; passed
6008 // through to any text-decl in external entities so 1.1 entities
6009 // are accepted when the host is 1.1.
6010 is_xml_11: bool,
6011) -> Result<()> {
6012 scan.expect(b'&')?;
6013 if scan.peek() == Some(b'#') {
6014 scan.advance();
6015 let cp: u32 = if scan.peek() == Some(b'x') {
6016 scan.advance();
6017 let start = scan.cur_pos();
6018 while scan.peek().map(|b| b.is_ascii_hexdigit()).unwrap_or(false) { scan.advance(); }
6019 if scan.cur_pos() == start { return Err(scan.err("empty hex character reference")); }
6020 let hex = scan.cur_str(start, scan.cur_pos());
6021 u32::from_str_radix(&hex, 16)
6022 .map_err(|_| scan.err(format!("invalid hex char ref: {hex}")))?
6023 } else {
6024 let start = scan.cur_pos();
6025 while scan.peek().map(|b| b.is_ascii_digit()).unwrap_or(false) { scan.advance(); }
6026 if scan.cur_pos() == start { return Err(scan.err("empty decimal character reference")); }
6027 let dec = scan.cur_str(start, scan.cur_pos());
6028 dec.parse::<u32>()
6029 .map_err(|_| scan.err(format!("invalid decimal char ref: {dec}")))?
6030 };
6031 scan.expect(b';')?;
6032 let c = char::from_u32(cp).ok_or_else(|| {
6033 scan.err(format!("U+{cp:04X} is not a valid Unicode scalar value"))
6034 })?;
6035 // XML 1.1 § 2.2 broadens the Char production to include the
6036 // C0 controls (#x1-#x1F) and #x7F-#x9F. Char refs (which
6037 // unlike raw bytes are explicitly the legal way to spell
6038 // these in a document) MUST be accepted in a 1.1 document
6039 // even though 1.0 forbids them.
6040 let valid = if is_xml_11 { is_xml_11_char(c) } else { is_xml_char(c) };
6041 if !valid {
6042 let spec = if is_xml_11 { "XML 1.1 § 2.2" } else { "XML 1.0 § 2.2" };
6043 return Err(scan.err(format!("U+{cp:04X} is not a valid XML character ({spec})")));
6044 }
6045 // UTF-8-encode the char into the byte buffer. For ASCII chars this
6046 // is one byte; for non-ASCII char-refs (e.g. `中`) we emit
6047 // the same UTF-8 sequence `String::push(c)` would produce.
6048 let mut tmp = [0u8; 4];
6049 buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes());
6050 } else {
6051 let (ns, ne) = scan.scan_name_raw()?;
6052 scan.expect(b';')?;
6053 let name = scan.cur_str(ns, ne);
6054 match name.as_ref() {
6055 "amp" => buf.push(b'&'),
6056 "lt" => buf.push(b'<'),
6057 "gt" => buf.push(b'>'),
6058 "quot" => buf.push(b'"'),
6059 "apos" => buf.push(b'\''),
6060 other => {
6061 // Look the entity up. Three categories:
6062 // * `InternalText` / `ExternalLoaded` — we have
6063 // replacement text; push it as a stream below.
6064 // * `ExternalUnloaded` — declared external but the
6065 // resolver wasn't configured or refused. In
6066 // libxml2-compat mode silently expand to empty;
6067 // otherwise treated like an undefined name (the
6068 // downstream pe_relax / recovery / strict error
6069 // handling applies).
6070 // * Not in the map — genuinely undefined.
6071 let decl = entities.get(other);
6072 let kind = decl.map(|d| &d.kind);
6073 // XML 1.0 § 2.9 + § 4.1 WFC: Entity Declared. In a
6074 // `standalone="yes"` document, references to entities
6075 // whose declaration lived in the external subset (or
6076 // in an external PE's replacement text) are not-WF.
6077 // Predefined names (amp/lt/gt/quot/apos) and entities
6078 // we declared internally are fine.
6079 if standalone_yes
6080 && decl.is_some_and(|d| d.declared_external)
6081 {
6082 return Err(scan.err(format!(
6083 "reference to entity '&{other};' declared in the external \
6084 subset is not allowed in a standalone='yes' document \
6085 (XML 1.0 § 2.9 / § 4.1 WFC: Entity Declared)"
6086 )));
6087 }
6088 let frame_base = decl.and_then(|d| d.source_uri.clone());
6089 let is_external = matches!(kind,
6090 Some(EntityKind::ExternalLoaded(_)) | Some(EntityKind::ExternalUnloaded));
6091 let value = match kind {
6092 Some(EntityKind::InternalText(v))
6093 | Some(EntityKind::ExternalLoaded(v)) => v.clone(),
6094 Some(EntityKind::ExternalUnloaded) | None => {
6095 // libxml2-compat treats unloaded externals as
6096 // silently expanding to empty.
6097 if scan.opts.libxml2_compat
6098 && matches!(kind, Some(EntityKind::ExternalUnloaded))
6099 {
6100 return Ok(());
6101 }
6102 // XML 1.0 § 4.1 WFC: Entity Declared carve-out:
6103 // refs that appear *inside* an external
6104 // entity's replacement text are exempt — at
6105 // most a VC violation, which non-validating
6106 // parsers MUST tolerate. Log a recoverable
6107 // warning and expand to empty so the parse
6108 // continues.
6109 if scan.current_base_uri().is_some() {
6110 if let Some(sink) = recovered {
6111 sink.push(scan.err_with_level(
6112 crate::error::ErrorLevel::Warning,
6113 format!(
6114 "undefined entity '&{other};' inside an external \
6115 entity — WFC: Entity Declared carve-out applies \
6116 (XML 1.0 § 4.1); expansion skipped"
6117 ),
6118 ));
6119 }
6120 return Ok(());
6121 }
6122 // XML 1.0 errata E13: if any PE reference
6123 // appeared in the internal DTD subset, an
6124 // undeclared general entity is a validity
6125 // error, not WF. Accept the doc and let
6126 // the reference expand to nothing — the
6127 // PE could have declared it, we can't tell.
6128 if pe_relax_undefined {
6129 return Ok(());
6130 }
6131 // Recovery (libxml2 XML_PARSE_RECOVER style):
6132 // leave the reference literal in the buffer
6133 // and continue. The caller's text/attr
6134 // value will contain `&name;` verbatim — a
6135 // best-effort representation of input the
6136 // parser couldn't resolve.
6137 if scan.opts.recovery_mode {
6138 if let Some(sink) = recovered {
6139 sink.push(scan.err_with_level(
6140 crate::error::ErrorLevel::Error,
6141 format!(
6142 "undefined entity '&{other};' — left as literal text \
6143 (XML 1.0 § 4.1 WFC: Entity Declared)"
6144 ),
6145 ));
6146 }
6147 buf.push(b'&');
6148 buf.extend_from_slice(other.as_bytes());
6149 buf.push(b';');
6150 return Ok(());
6151 }
6152 return Err(scan.err(format!(
6153 "undefined entity '&{other};' (XML 1.0 § 4.1 WFC: Entity Declared)"
6154 )));
6155 }
6156 };
6157 *used = used.saturating_add(value.len() as u64);
6158 if *used > budget {
6159 return Err(scan.err(format!(
6160 "entity expansion limit exceeded ({budget} bytes) — possible \
6161 entity expansion attack (CVE-2003-1564)"
6162 )));
6163 }
6164 scan.push_entity_stream(other.to_string(), value, element_depth, frame_base)?;
6165 if is_external {
6166 // XML 1.0 §4.3.1: only external parsed entities
6167 // may begin with a text declaration. Validating
6168 // it on internal entities would silently accept
6169 // not-wf inputs like `<!ENTITY e "<?xml ?>">`.
6170 consume_text_decl_if_present(scan, is_xml_11)?;
6171 }
6172 }
6173 }
6174 }
6175 Ok(())
6176}
6177
6178/// Resolve a SYSTEM literal to a filesystem path (joining against
6179/// `base_url`'s parent directory for relative literals), read the
6180/// file, and return its bytes. Used by external general-entity
6181/// loading; mirrors the path-resolution rule in
6182/// [`XmlBytesReader::load_external_subset`].
6183///
6184/// `http://` and `https://` URIs are rejected — v0.1 doesn't fetch
6185/// over the network. `file://` prefixes are stripped.
6186/// Resolve a (possibly relative) SYSTEM identifier against a base URI
6187/// into an absolute URL string.
6188///
6189/// The parser is the authority on base URI semantics — see XML 1.0
6190/// § 4.2.2 + errata E18. Doing the join here means every
6191/// [`EntityResolver`] implementation can stay a simple
6192/// "open-this-URL" function rather than re-implementing URI math.
6193///
6194/// Rules:
6195/// * If `system_id` already has a URI scheme (contains `"://"`) or
6196/// is an absolute filesystem path (starts with `/`), it's returned
6197/// verbatim — already absolute.
6198/// * If `base` is `None`, `system_id` is returned verbatim (best
6199/// effort — the resolver will handle relative paths as it sees fit).
6200/// * Otherwise the parent directory of `base` is joined with
6201/// `system_id` (preserving any `file://` scheme on `base`).
6202///
6203/// The result may still contain `..` segments — they're left for the
6204/// resolver's own canonicalisation step (e.g.
6205/// `FilesystemResolver::validate_path` calls `canonicalize`, which
6206/// resolves them and follows the security check on the result).
6207///
6208/// [`EntityResolver`]: crate::entity_resolver::EntityResolver
6209pub fn resolve_uri(system_id: &str, base: Option<&str>) -> String {
6210 // Already-absolute forms pass through.
6211 if system_id.contains("://") || system_id.starts_with('/') {
6212 return system_id.to_string();
6213 }
6214 let Some(base) = base else { return system_id.to_string(); };
6215 // Strip and re-attach `file://` so path joining works on raw
6216 // path components. Non-file schemes already short-circuited above.
6217 let (scheme, base_path) = match base.strip_prefix("file://") {
6218 Some(rest) => ("file://", rest),
6219 None => ("", base),
6220 };
6221 // A URI is always '/'-separated regardless of host OS, so join as
6222 // strings rather than through `std::path` (whose separator is `\` on
6223 // Windows — that corrupts the URI and breaks the resolver's fixture /
6224 // canonicalisation lookups). Take the base's directory (everything up
6225 // to and including its last '/') and append the relative system id;
6226 // any `..` segments are left for the resolver to canonicalise.
6227 let parent = match base_path.rfind('/') {
6228 Some(i) => &base_path[..=i],
6229 None => "",
6230 };
6231 format!("{scheme}{parent}{system_id}")
6232}
6233
6234fn read_external_entity_bytes(
6235 system_id: &str,
6236 base_url: Option<&str>,
6237) -> std::result::Result<Vec<u8>, String> {
6238 if system_id.starts_with("http://") || system_id.starts_with("https://") {
6239 return Err("network URIs not supported".to_string());
6240 }
6241 let raw: &str = system_id.strip_prefix("file://").unwrap_or(system_id);
6242 let resolved: std::path::PathBuf = {
6243 let pb = std::path::Path::new(raw);
6244 if pb.is_absolute() {
6245 pb.to_path_buf()
6246 } else if let Some(base) = base_url {
6247 let base_path = std::path::Path::new(base.strip_prefix("file://").unwrap_or(base));
6248 match base_path.parent() {
6249 Some(dir) => dir.join(pb),
6250 None => pb.to_path_buf(),
6251 }
6252 } else {
6253 pb.to_path_buf()
6254 }
6255 };
6256 std::fs::read(&resolved).map_err(|e| e.to_string())
6257}
6258
6259// ── tests ─────────────────────────────────────────────────────────────────────
6260
6261#[cfg(test)]
6262mod tests {
6263 use super::*;
6264
6265 // SAFETY in this test module: every byte slice handed to `as_utf8`
6266 // came from a `&'static str` literal we passed into the reader, so
6267 // it's valid UTF-8 by construction. Used only for readable error
6268 // messages in test asserts.
6269 fn as_utf8(b: &[u8]) -> &str { std::str::from_utf8(b).unwrap() }
6270
6271 fn events(src: &str) -> Vec<String> {
6272 let mut r = XmlBytesReader::from_str(src);
6273 let mut out = Vec::new();
6274 let mut buf = Vec::new();
6275 loop {
6276 match r.next_into(&mut buf).unwrap() {
6277 BytesEventInto::StartElement { name } => {
6278 let a: Vec<_> = buf.iter()
6279 .map(|a| format!("{}={}", as_utf8(&a.name), as_utf8(&a.value)))
6280 .collect();
6281 let name = as_utf8(&name);
6282 if a.is_empty() { out.push(format!("<{name}>")); }
6283 else { out.push(format!("<{name} {}>", a.join(" "))); }
6284 }
6285 BytesEventInto::EndElement { name } => out.push(format!("</{}>", as_utf8(&name))),
6286 BytesEventInto::Text(t) => out.push(format!("T:{}", as_utf8(&t))),
6287 BytesEventInto::CData(s) => out.push(format!("CD:{}", as_utf8(&s))),
6288 BytesEventInto::Comment(s) => out.push(format!("C:{}", as_utf8(&s))),
6289 BytesEventInto::Pi { target, .. } => out.push(format!("PI:{}", as_utf8(&target))),
6290 BytesEventInto::EntityRef { name } => out.push(format!("E:{}", as_utf8(&name))),
6291 BytesEventInto::Eof => break,
6292 }
6293 }
6294 out
6295 }
6296
6297 #[test]
6298 fn minimal() {
6299 assert_eq!(events("<r/>"), vec!["<r>", "</r>"]);
6300 }
6301
6302 #[test]
6303 fn nested() {
6304 assert_eq!(
6305 events("<a><b>hello</b></a>"),
6306 vec!["<a>", "<b>", "T:hello", "</b>", "</a>"],
6307 );
6308 }
6309
6310 #[test]
6311 fn attributes_borrowed() {
6312 let src = r#"<el id="1" class="x"/>"#;
6313 let mut r = XmlBytesReader::from_str(src);
6314 let mut buf = Vec::new();
6315 let ev = r.next_into(&mut buf).unwrap();
6316 assert!(matches!(&ev, BytesEventInto::StartElement { name } if &name[..] == b"el"));
6317 assert_eq!(buf.len(), 2);
6318 assert!(matches!(buf[0].value, Cow::Borrowed(_)), "no entity → should borrow");
6319 }
6320
6321 #[test]
6322 fn attribute_entity_owned() {
6323 let src = r#"<el v="a&b"/>"#;
6324 let mut r = XmlBytesReader::from_str(src);
6325 let mut buf = Vec::new();
6326 r.next_into(&mut buf).unwrap();
6327 assert_eq!(&*buf[0].value(), b"a&b");
6328 assert!(matches!(buf[0].value, Cow::Owned(_)), "entity → must allocate");
6329 }
6330
6331 #[test]
6332 fn text_borrowed() {
6333 let src = "<r>hello world</r>";
6334 let mut r = XmlBytesReader::from_str(src);
6335 let mut buf = Vec::new();
6336 r.next_into(&mut buf).unwrap(); // StartElement
6337 let ev = r.next_into(&mut buf).unwrap();
6338 match ev {
6339 BytesEventInto::Text(Cow::Borrowed(b)) => assert_eq!(b, b"hello world"),
6340 other => panic!("expected borrowed Text, got {other:?}"),
6341 }
6342 }
6343
6344 /// Sanity-check the lazy contract end-to-end: pattern-match on the
6345 /// new wrapper variants and call methods to extract data. Pure
6346 /// behavioural test — covered by other tests too, but kept here as
6347 /// the explicit "the new shape works" smoke test.
6348 #[test]
6349 fn lazy_event_methods_smoke_test() {
6350 let mut r = XmlBytesReader::from_str(
6351 "<root><!-- c --><![CDATA[cd]]><?p y?><a x=\"1\">hi</a></root>"
6352 );
6353 let mut got: Vec<String> = Vec::new();
6354 loop {
6355 match r.next().unwrap() {
6356 BytesEvent::StartElement(tag) => {
6357 let attrs: Vec<_> = tag.attrs()
6358 .map(|a| {
6359 let a = a.unwrap();
6360 format!("{}={}", as_utf8(a.name), as_utf8(&a.value))
6361 })
6362 .collect();
6363 let name_part = format!("<{}", as_utf8(
6364 // tag was consumed by attrs(); use the saved attrs vec for the assertion shape
6365 b"_"
6366 ));
6367 // Using the attrs vec is the pattern-match-safe way
6368 // to keep both name and attrs; the lazy API forces
6369 // a choice via attrs() consuming the tag. Real
6370 // users would call `let name = tag.name(); let _ = tag.attrs();`.
6371 let _ = name_part;
6372 if attrs.is_empty() { got.push("<a>".into()); }
6373 else { got.push(format!("<a {}>", attrs.join(" "))); }
6374 }
6375 BytesEvent::EndElement(tag) => got.push(format!("</{}>", as_utf8(tag.name()))),
6376 BytesEvent::Text(t) => got.push(format!("T:{}", as_utf8(t.as_bytes()))),
6377 BytesEvent::CData(s) => got.push(format!("CD:{}", as_utf8(s.as_bytes()))),
6378 BytesEvent::Comment(s) => got.push(format!("C:{}", as_utf8(s.as_bytes()))),
6379 BytesEvent::Pi(p) => got.push(format!("PI:{}", as_utf8(p.target()))),
6380 BytesEvent::EntityRef(e) => got.push(format!("E:{}", as_utf8(e.name()))),
6381 BytesEvent::Eof => break,
6382 }
6383 }
6384 // The first StartElement we discarded the name (because attrs()
6385 // consumed `tag`); we just check shape and content of the rest.
6386 assert!(got.iter().any(|s| s == "C: c "));
6387 assert!(got.iter().any(|s| s == "CD:cd"));
6388 assert!(got.iter().any(|s| s == "PI:p"));
6389 assert!(got.iter().any(|s| s == "<a x=1>"));
6390 assert!(got.iter().any(|s| s == "T:hi"));
6391 assert!(got.iter().any(|s| s == "</a>"));
6392 assert!(got.iter().any(|s| s == "</root>"));
6393 }
6394
6395 #[test]
6396 fn cdata_borrowed() {
6397 let src = "<r><![CDATA[raw <data>]]></r>";
6398 assert_eq!(events(src), vec!["<r>", "CD:raw <data>", "</r>"]);
6399 }
6400
6401 #[test]
6402 fn empty_element_emits_both_events() {
6403 assert_eq!(events("<root><br/></root>"), vec!["<root>", "<br>", "</br>", "</root>"]);
6404 }
6405
6406 #[test]
6407 fn buffer_reuse() {
6408 let src = "<a x='1'/><b y='2'/>";
6409 let src = format!("<root>{src}</root>");
6410 let mut r = XmlBytesReader::from_str(&src);
6411 let mut buf: Vec<BytesAttr> = Vec::new();
6412 let cap_before;
6413 loop {
6414 match r.next_into(&mut buf).unwrap() {
6415 BytesEventInto::StartElement { name } if &name[..] == b"a" => {
6416 cap_before = buf.capacity();
6417 break;
6418 }
6419 BytesEventInto::Eof => panic!("unexpected EOF"),
6420 _ => {}
6421 }
6422 }
6423 loop {
6424 match r.next_into(&mut buf).unwrap() {
6425 BytesEventInto::StartElement { name } if &name[..] == b"b" => {
6426 assert_eq!(buf.capacity(), cap_before, "capacity should not grow for same-size attrs");
6427 break;
6428 }
6429 BytesEventInto::Eof => panic!("unexpected EOF"),
6430 _ => {}
6431 }
6432 }
6433 }
6434
6435 #[test]
6436 fn lazy_attrs_iter() {
6437 let src = r#"<el id="1" class="x"/>"#;
6438 let mut r = XmlBytesReader::from_str(src);
6439 match r.next().unwrap() {
6440 BytesEvent::StartElement(tag) => {
6441 assert_eq!(tag.name(), b"el");
6442 let pairs: Vec<(Vec<u8>, Vec<u8>)> = tag.attrs()
6443 .map(|a| a.map(|a| (a.name.to_vec(), a.value.into_owned())).unwrap())
6444 .collect();
6445 assert_eq!(pairs, vec![
6446 (b"id".to_vec(), b"1".to_vec()),
6447 (b"class".to_vec(), b"x".to_vec()),
6448 ]);
6449 }
6450 _ => panic!("expected StartElement"),
6451 }
6452 }
6453
6454 #[test]
6455 fn lazy_attrs_skipped_costs_nothing() {
6456 let src = r#"<el id="1" class="x"/>"#;
6457 let mut r = XmlBytesReader::from_str(src);
6458 match r.next().unwrap() {
6459 BytesEvent::StartElement(tag) => assert_eq!(tag.name(), b"el"),
6460 _ => panic!(),
6461 }
6462 match r.next().unwrap() {
6463 BytesEvent::EndElement(tag) => assert_eq!(tag.name(), b"el"),
6464 _ => panic!(),
6465 }
6466 match r.next().unwrap() {
6467 BytesEvent::Eof => {}
6468 _ => panic!(),
6469 }
6470 }
6471
6472 // ── regression: skip_end_tag_check must NOT swallow whitespace ──
6473 //
6474 // Earlier, depth incremented only when `track_stack` was true (which
6475 // is `!skip_end_tag_check`), so turning skip_end_tag_check on left
6476 // depth at 0 forever, and the `if depth == 0 { skip_ws() }` path in
6477 // `next()` ran on every call — silently eating every inter-element
6478 // whitespace text event. These tests pin the correct behaviour:
6479 // depth tracking is independent of end-tag enforcement, and
6480 // whitespace text events are emitted in both modes.
6481
6482 fn count_text_events(src: &str, opts: ParseOptions) -> (u32, u32) {
6483 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src.as_bytes()) }
6484 .with_options(opts);
6485 let (mut total, mut ws) = (0u32, 0u32);
6486 loop {
6487 match r.next().unwrap() {
6488 BytesEvent::Eof => return (total, ws),
6489 BytesEvent::Text(t) => {
6490 total += 1;
6491 if !t.as_bytes().is_empty()
6492 && t.as_bytes().iter().all(|b| b.is_ascii_whitespace())
6493 {
6494 ws += 1;
6495 }
6496 }
6497 _ => {}
6498 }
6499 }
6500 }
6501
6502 #[test]
6503 fn whitespace_preserved_with_default_options() {
6504 // Baseline: depth tracking on, whitespace text events emitted.
6505 let src = "<root>\n <a/>\n <b/>\n</root>";
6506 let (total, ws) = count_text_events(src, ParseOptions::default());
6507 assert_eq!(ws, 3, "default mode should emit 3 whitespace text events");
6508 assert_eq!(total, 3);
6509 }
6510
6511 #[test]
6512 fn whitespace_preserved_with_skip_end_tag_check() {
6513 // Regression: skip_end_tag_check used to silently swallow
6514 // whitespace. With the fix, the same 3 whitespace text events
6515 // appear regardless of the end-tag flag.
6516 let src = "<root>\n <a/>\n <b/>\n</root>";
6517 let opts = ParseOptions { skip_end_tag_check: true, ..ParseOptions::default() };
6518 let (total, ws) = count_text_events(src, opts);
6519 assert_eq!(ws, 3,
6520 "skip_end_tag_check should NOT swallow inter-element whitespace");
6521 assert_eq!(total, 3);
6522 }
6523
6524 #[test]
6525 fn skip_end_tag_check_still_disables_end_tag_match() {
6526 // The flag must still do its real job: accept mismatched
6527 // end tags without erroring.
6528 let opts = ParseOptions { skip_end_tag_check: true, ..ParseOptions::default() };
6529 let mut r = XmlBytesReader::from_str("<a></b>").with_options(opts);
6530 // Should NOT error on mismatched a/b.
6531 let mut events = 0u32;
6532 loop {
6533 match r.next().unwrap() {
6534 BytesEvent::Eof => break,
6535 _ => events += 1,
6536 }
6537 }
6538 assert!(events >= 2, "got both Start and End despite mismatch");
6539 }
6540
6541 #[test]
6542 fn top_level_whitespace_still_skipped_in_both_modes() {
6543 // Whitespace BETWEEN top-level constructs (depth 0) was always
6544 // skipped — that part of the behaviour is correct and stays.
6545 // This is whitespace before the root and after the root close.
6546 let src = " \n <root/> \n ";
6547 for opts in [
6548 ParseOptions::default(),
6549 ParseOptions { skip_end_tag_check: true, ..ParseOptions::default() },
6550 ] {
6551 let (total, ws) = count_text_events(src, opts.clone());
6552 assert_eq!(ws, 0, "no whitespace text events at depth 0 (opts: {opts:?})");
6553 assert_eq!(total, 0);
6554 }
6555 }
6556
6557 // ── XML 1.0 § 2.1 / § 3.1 / § 2.8 well-formedness ───────────────
6558 //
6559 // These tests pin the bug fixes for the structural well-formedness
6560 // checks that were missing. Each one of these inputs is forbidden
6561 // by XML 1.0 — every other major XML parser (libxml2, roxmltree,
6562 // xml-rs) rejects them; we used to accept them. See the parallel
6563 // tests in `crates/bench/benches/text_validation_check.rs`.
6564
6565 fn parse_all(src: &str, opts: ParseOptions) -> Result<u32> {
6566 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src.as_bytes()) }
6567 .with_options(opts);
6568 let mut n = 0u32;
6569 loop {
6570 match r.next()? {
6571 BytesEvent::Eof => return Ok(n),
6572 _ => n += 1,
6573 }
6574 }
6575 }
6576
6577 #[test]
6578 fn rejects_unclosed_element_at_eof() {
6579 // XML 1.0 § 3.1: every start tag must have a matching end
6580 // tag. `<r><x>` ends with `<x>` still open.
6581 let err = parse_all("<r><x>", ParseOptions::default()).unwrap_err();
6582 assert!(err.to_string().contains("unclosed"),
6583 "expected 'unclosed' in error, got: {err}");
6584 }
6585
6586 #[test]
6587 fn unclosed_at_eof_relaxed_under_skip_end_tag_check() {
6588 // The opt-in flag relaxes the structural check for callers
6589 // streaming partial fragments.
6590 let opts = ParseOptions { skip_end_tag_check: true, ..ParseOptions::default() };
6591 assert!(parse_all("<r><x>", opts).is_ok());
6592 }
6593
6594 #[test]
6595 fn recover_mode_synthesises_closes_for_unclosed_at_eof() {
6596 // recovery_mode: true → unclosed elements at EOF
6597 // become synthetic EndElement events, and the errors are
6598 // collected on the reader.
6599 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6600 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<r><a><b>") }
6601 .with_options(opts);
6602 let mut closed: Vec<Vec<u8>> = Vec::new();
6603 loop {
6604 match r.next().unwrap() {
6605 BytesEvent::Eof => break,
6606 BytesEvent::EndElement(tag) => closed.push(tag.name().to_vec()),
6607 _ => {}
6608 }
6609 }
6610 // Three start tags → three synthetic closes, in
6611 // innermost-first order (b, a, r).
6612 assert_eq!(
6613 closed.iter().map(|n| String::from_utf8_lossy(n).into_owned()).collect::<Vec<_>>(),
6614 vec!["b".to_string(), "a".to_string(), "r".to_string()],
6615 );
6616 // Three errors logged, one per unclosed element.
6617 let errs = r.recovered_errors();
6618 assert_eq!(errs.len(), 3, "got {} errors", errs.len());
6619 assert!(errs.iter().all(|e| e.message.contains("unclosed")),
6620 "expected 'unclosed' in every recovery message");
6621 }
6622
6623 #[test]
6624 fn recover_mode_logs_empty_doc_error_but_continues() {
6625 // An empty document logs the "no root element" error in
6626 // recover mode and returns Eof immediately; nothing to
6627 // synthesise.
6628 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6629 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"") }
6630 .with_options(opts);
6631 assert!(matches!(r.next().unwrap(), BytesEvent::Eof));
6632 let errs = r.recovered_errors();
6633 assert_eq!(errs.len(), 1);
6634 assert!(errs[0].message.contains("no root element"));
6635 }
6636
6637 #[test]
6638 fn strict_mode_errors_unchanged_in_phase1() {
6639 // Sanity: turning recovery_mode OFF (default) keeps
6640 // every error fatal. No regression vs pre-recovery
6641 // behaviour.
6642 assert!(parse_all("<r><x>", ParseOptions::default()).is_err());
6643 }
6644
6645 #[test]
6646 fn recover_mode_walks_stack_for_mismatched_end_tag() {
6647 // libxml2-style recovery: `<a><b><c></a>` — `</a>` doesn't
6648 // match the top of stack `c`, but `a` IS on the stack at
6649 // depth 0. Synthesise closes for c and b, then close a.
6650 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6651 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<a><b><c></a>") }
6652 .with_options(opts);
6653 let mut closed: Vec<Vec<u8>> = Vec::new();
6654 loop {
6655 match r.next().unwrap() {
6656 BytesEvent::Eof => break,
6657 BytesEvent::EndElement(tag) => closed.push(tag.name().to_vec()),
6658 _ => {}
6659 }
6660 }
6661 assert_eq!(
6662 closed.iter().map(|n| String::from_utf8_lossy(n).into_owned()).collect::<Vec<_>>(),
6663 vec!["c".to_string(), "b".to_string(), "a".to_string()],
6664 "expected innermost-first synth closes then real `</a>`",
6665 );
6666 // 2 errors logged: the first for top=c expected, got=a;
6667 // the second for top=b expected, got=a.
6668 // (When the cursor finally aligns at depth 0 with `</a>`
6669 // matching top=a, we stop logging.)
6670 let errs = r.recovered_errors();
6671 assert!(errs.len() >= 1, "got {} errors", errs.len());
6672 assert!(errs.iter().all(|e| e.message.contains("mismatched")),
6673 "expected 'mismatched' in recovery messages");
6674 }
6675
6676 #[test]
6677 fn recover_mode_drops_orphan_end_tag() {
6678 // `</orphan>` with no open element → log error, discard
6679 // the tag, continue with the rest of the document.
6680 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6681 let r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<r></orphan></r>") }
6682 .with_options(opts.clone());
6683 // Element-stack now has `r`. We see `</orphan>`, which
6684 // doesn't match `r` — that's "mismatched", not "orphan".
6685 // The orphan path fires when the stack is EMPTY at the
6686 // moment of an unmatched end tag — exercise via:
6687 let mut r2 = unsafe { XmlBytesReader::from_bytes_unchecked(b"</orphan><r/>") }
6688 .with_options(opts);
6689 let mut events = 0u32;
6690 loop {
6691 match r2.next().unwrap() {
6692 BytesEvent::Eof => break,
6693 _ => events += 1,
6694 }
6695 }
6696 assert!(events >= 2, "got {} events from `</orphan><r/>`", events);
6697 let errs = r2.recovered_errors();
6698 assert!(errs.iter().any(|e| e.message.contains("orphan")
6699 || e.message.contains("no open")),
6700 "expected orphan/no-open error, got: {:?}",
6701 errs.iter().map(|e| &e.message).collect::<Vec<_>>());
6702 // (Suppress unused-warning for r — kept for future
6703 // expansion.)
6704 let _ = r;
6705 }
6706
6707 #[test]
6708 fn recover_mode_accepts_second_root() {
6709 // `<a/><b/>` — XML 1.0 § 2.1 forbids two root elements. In
6710 // recover mode, log the error and accept anyway.
6711 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6712 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<a/><b/>") }
6713 .with_options(opts);
6714 let mut starts: Vec<Vec<u8>> = Vec::new();
6715 loop {
6716 match r.next().unwrap() {
6717 BytesEvent::Eof => break,
6718 BytesEvent::StartElement(t) => starts.push(t.name().to_vec()),
6719 _ => {}
6720 }
6721 }
6722 assert_eq!(
6723 starts.iter().map(|n| String::from_utf8_lossy(n).into_owned()).collect::<Vec<_>>(),
6724 vec!["a".to_string(), "b".to_string()],
6725 "both root elements should be emitted in recover mode",
6726 );
6727 let errs = r.recovered_errors();
6728 assert!(errs.iter().any(|e| e.message.contains("one root")),
6729 "expected 'one root' error, got: {:?}",
6730 errs.iter().map(|e| &e.message).collect::<Vec<_>>());
6731 }
6732
6733 #[test]
6734 fn recover_mode_keeps_bare_lt_literal_in_text() {
6735 // Bare `<` followed by non-name-start in text content —
6736 // strict rejects, recover keeps the `<` literal across
6737 // a sequence of Text events. Better than libxml2 which
6738 // silently drops the `<`.
6739 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6740 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<r>1 < 2</r>") }
6741 .with_options(opts);
6742 let mut text = String::new();
6743 loop {
6744 match r.next().unwrap() {
6745 BytesEvent::Eof => break,
6746 BytesEvent::Text(t) => text.push_str(&String::from_utf8_lossy(t.as_bytes())),
6747 _ => {}
6748 }
6749 }
6750 assert_eq!(text, "1 < 2",
6751 "all bytes preserved (libxml2 would silently drop the '<')");
6752 assert!(r.recovered_errors().iter().any(|e| e.message.contains("bare '<'")),
6753 "expected bare-< error logged");
6754 }
6755
6756 #[test]
6757 fn recover_mode_accepts_text_before_root() {
6758 // `hello<r/>` — text at doc level is forbidden, but in
6759 // recover mode we emit it as a Text event so the user
6760 // can see what was there. Better than libxml2 which
6761 // sometimes loses the root element entirely.
6762 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6763 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"hello<r/>") }
6764 .with_options(opts);
6765 let mut events: Vec<String> = Vec::new();
6766 loop {
6767 match r.next().unwrap() {
6768 BytesEvent::Eof => break,
6769 BytesEvent::Text(t) =>
6770 events.push(format!("Text({:?})",
6771 String::from_utf8_lossy(t.as_bytes()))),
6772 BytesEvent::StartElement(t) =>
6773 events.push(format!("Start({})",
6774 String::from_utf8_lossy(t.name()))),
6775 BytesEvent::EndElement(t) =>
6776 events.push(format!("End({})",
6777 String::from_utf8_lossy(t.name()))),
6778 _ => {}
6779 }
6780 }
6781 assert!(events.iter().any(|e| e.contains("Text(\"hello\")")),
6782 "expected leading text preserved, events: {:?}", events);
6783 assert!(events.iter().any(|e| e.contains("Start(r)")),
6784 "expected root start emitted, events: {:?}", events);
6785 assert!(r.recovered_errors().iter().any(|e| e.message.contains("document level")),
6786 "expected doc-level error logged");
6787 }
6788
6789 #[test]
6790 fn recover_mode_accepts_text_after_root() {
6791 // `<r/>trailing text` — text after the root close is
6792 // also a violation, also preserved in recover mode.
6793 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6794 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<r/>trailing text") }
6795 .with_options(opts);
6796 let mut text = String::new();
6797 loop {
6798 match r.next().unwrap() {
6799 BytesEvent::Eof => break,
6800 BytesEvent::Text(t) => text.push_str(&String::from_utf8_lossy(t.as_bytes())),
6801 _ => {}
6802 }
6803 }
6804 assert_eq!(text, "trailing text",
6805 "trailing text preserved in recover mode");
6806 }
6807
6808 #[test]
6809 fn recover_mode_keeps_cdata_close_literal_in_text() {
6810 // `]]>` in text — strict mode rejects, recover mode keeps
6811 // the bytes literal in the text payload. Better than
6812 // libxml2's behaviour (which silently mangles surrounding
6813 // text).
6814 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6815 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<r>oops]]>more</r>") }
6816 .with_options(opts);
6817 let mut text: Option<Vec<u8>> = None;
6818 loop {
6819 match r.next().unwrap() {
6820 BytesEvent::Eof => break,
6821 BytesEvent::Text(t) => text = Some(t.as_bytes().to_vec()),
6822 _ => {}
6823 }
6824 }
6825 let text = text.expect("expected a text event");
6826 assert_eq!(
6827 String::from_utf8_lossy(&text), "oops]]>more",
6828 "all bytes preserved literal in recover mode",
6829 );
6830 assert!(r.recovered_errors().iter().any(|e| e.message.contains("]]>")),
6831 "expected ']]>' error logged");
6832 }
6833
6834 #[test]
6835 fn recover_mode_keeps_bare_amp_literal_in_text() {
6836 // Bare `&` in text — strict mode rejects (must be
6837 // `&` or a real reference), recover mode keeps it
6838 // literal. Better than libxml2 which DROPS the `&`.
6839 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6840 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<r>tom & jerry</r>") }
6841 .with_options(opts);
6842 let mut text: Option<Vec<u8>> = None;
6843 loop {
6844 match r.next().unwrap() {
6845 BytesEvent::Eof => break,
6846 BytesEvent::Text(t) => text = Some(t.as_bytes().to_vec()),
6847 _ => {}
6848 }
6849 }
6850 let text = text.expect("expected a text event");
6851 assert_eq!(
6852 String::from_utf8_lossy(&text), "tom & jerry",
6853 "bare '&' preserved in recover mode (libxml2 silently drops it)",
6854 );
6855 assert!(r.recovered_errors().iter().any(|e| e.message.contains("bare '&'")),
6856 "expected bare-& error logged");
6857 }
6858
6859 #[test]
6860 fn recover_mode_skips_malformed_xml_decl() {
6861 // `<?xml?>` — missing version. Strict rejects; recover
6862 // logs and resyncs past `?>` so the rest of the document
6863 // parses normally.
6864 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6865 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<?xml?><r>ok</r>") }
6866 .with_options(opts);
6867 let mut starts: Vec<Vec<u8>> = Vec::new();
6868 loop {
6869 match r.next() {
6870 Ok(BytesEvent::Eof) => break,
6871 Ok(BytesEvent::StartElement(t)) => starts.push(t.name().to_vec()),
6872 Ok(_) => {}
6873 Err(e) => panic!("next() errored unexpectedly in recover mode: {e}"),
6874 }
6875 }
6876 assert_eq!(
6877 starts.iter().map(|n| String::from_utf8_lossy(n).into_owned()).collect::<Vec<_>>(),
6878 vec!["r".to_string()],
6879 "root element should still parse after malformed decl",
6880 );
6881 assert!(r.recovered_errors().iter().any(|e| e.message.to_lowercase().contains("xml decl")
6882 || e.message.contains("XMLDecl")
6883 || e.message.contains("version")),
6884 "expected XML-decl error logged, got: {:?}",
6885 r.recovered_errors().iter().map(|e| &e.message).collect::<Vec<_>>());
6886 }
6887
6888 /// XML 1.0 5th-edition (the default) accepts CJK combining marks
6889 /// like `U+309A` as a NameStartChar via the `U+3001..=U+D7FF`
6890 /// range. XML 1.0 4th-edition rejects them — they belong to the
6891 /// removed `CombiningChar` production, not `Letter`. Same input
6892 /// flips outcome based on `ParseOptions::xml10_fourth_edition`,
6893 /// matching libxml2's `XML_PARSE_OLD10` behaviour.
6894 #[test]
6895 fn name_start_combining_mark_accepted_5e_rejected_4e() {
6896 let src = "<\u{309A}/>".as_bytes().to_vec();
6897
6898 // 5th edition (modern default) — accept.
6899 let mut r5 = unsafe { XmlBytesReader::from_bytes_unchecked(&src) };
6900 let mut hit_start_5e = false;
6901 loop {
6902 match r5.next().expect("5th-edition parse should accept U+309A") {
6903 BytesEvent::Eof => break,
6904 BytesEvent::StartElement(_) => hit_start_5e = true,
6905 _ => {}
6906 }
6907 }
6908 assert!(hit_start_5e, "5th-edition: U+309A should open a Start event");
6909
6910 // 4th edition (opt-in) — reject as invalid NameStartChar.
6911 let opts = ParseOptions {
6912 xml10_fourth_edition: true,
6913 ..ParseOptions::default()
6914 };
6915 let mut r4 = unsafe { XmlBytesReader::from_bytes_unchecked(&src) }
6916 .with_options(opts);
6917 let err = loop {
6918 match r4.next() {
6919 Ok(BytesEvent::Eof) => panic!("4th-edition: should have rejected U+309A name start"),
6920 Ok(_) => continue,
6921 Err(e) => break e,
6922 }
6923 };
6924 assert!(err.message.contains("name-start") || err.message.contains("name start")
6925 || err.message.to_lowercase().contains("invalid name"),
6926 "4th-edition rejection should mention name-start invalidity, got: {}",
6927 err.message);
6928 }
6929
6930 /// `U+00B7` (middle dot) is excluded from NameStartChar in BOTH
6931 /// editions — 4e tags it as `Extender` (NameChar only, not
6932 /// NameStartChar); 5e's NameStartChar ranges similarly exclude
6933 /// it. Parser rejection should be edition-independent.
6934 #[test]
6935 fn name_start_middle_dot_rejected_in_both_editions() {
6936 let src = "<\u{00B7}/>".as_bytes().to_vec();
6937
6938 for fourth in [false, true] {
6939 let opts = ParseOptions { xml10_fourth_edition: fourth, ..ParseOptions::default() };
6940 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(&src) }
6941 .with_options(opts);
6942 let err = loop {
6943 match r.next() {
6944 Ok(BytesEvent::Eof) => panic!("edition={fourth}: U+00B7 should be rejected"),
6945 Ok(_) => continue,
6946 Err(e) => break e,
6947 }
6948 };
6949 assert!(err.message.to_lowercase().contains("name"),
6950 "edition={fourth}: rejection should mention name, got: {}", err.message);
6951 }
6952 }
6953
6954 #[test]
6955 fn recover_mode_leaves_undefined_entity_literal() {
6956 // `&xyz;` is undefined. In recover mode the text event
6957 // contains `&xyz;` verbatim and the error is logged.
6958 let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
6959 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(b"<r>before &xyz; after</r>") }
6960 .with_options(opts);
6961 let mut text_seen: Option<Vec<u8>> = None;
6962 loop {
6963 match r.next().unwrap() {
6964 BytesEvent::Eof => break,
6965 BytesEvent::Text(t) => text_seen = Some(t.as_bytes().to_vec()),
6966 _ => {}
6967 }
6968 }
6969 let text = text_seen.expect("expected a text event");
6970 assert!(text.windows(5).any(|w| w == b"&xyz;"),
6971 "expected `&xyz;` literal in text, got: {:?}",
6972 String::from_utf8_lossy(&text));
6973 let errs = r.recovered_errors();
6974 assert!(errs.iter().any(|e| e.message.contains("undefined entity")
6975 && e.message.contains("xyz")),
6976 "expected undefined-entity error, got: {:?}",
6977 errs.iter().map(|e| &e.message).collect::<Vec<_>>());
6978 }
6979
6980 // ── external entity resolver wiring ────────────────────────
6981
6982 #[test]
6983 fn external_entity_with_resolver_loads_replacement_text() {
6984 // With an InMemoryResolver mapping the system_id, an
6985 // external entity reference expands to the resolver's
6986 // bytes — same code path as if it were declared inline.
6987 use crate::entity_resolver::InMemoryResolver;
6988 use std::sync::Arc;
6989
6990 let resolver = Arc::new(
6991 InMemoryResolver::new()
6992 .with_system("file:///fake/foo.ent", b"hello world".to_vec())
6993 );
6994 let opts = ParseOptions {
6995 external_resolver: Some(resolver),
6996 ..ParseOptions::default()
6997 };
6998 let src = br#"<!DOCTYPE doc [
6999 <!ENTITY foo SYSTEM "file:///fake/foo.ent">
7000 ]>
7001 <doc>&foo;</doc>"#;
7002 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src) }
7003 .with_options(opts);
7004 let mut got: Option<Vec<u8>> = None;
7005 loop {
7006 match r.next().unwrap() {
7007 BytesEvent::Eof => break,
7008 BytesEvent::Text(t) if t.as_bytes() != b"\n "
7009 && !t.as_bytes().iter().all(u8::is_ascii_whitespace)
7010 => got = Some(t.as_bytes().to_vec()),
7011 _ => {}
7012 }
7013 }
7014 let text = got.expect("expected text event with resolved content");
7015 assert_eq!(String::from_utf8_lossy(&text), "hello world",
7016 "resolver bytes should be the replacement text");
7017 }
7018
7019 #[test]
7020 fn external_entity_with_markup_replacement_text_parses_as_subtree() {
7021 // External entity whose replacement text contains element
7022 // markup (rather than plain text) should expand into a
7023 // sub-tree: the parser sees `&foo;` and continues reading
7024 // `<evil>XML</evil>` from the pushed entity stream, surfacing
7025 // a Start event, the inner Text, and a balanced End event.
7026 // This exercises element_stack handling across the entity
7027 // boundary — the start tag was scanned from inside the
7028 // entity, so its source-offset would be meaningless against
7029 // the document's `src_bytes()`. Pre-fix this surfaced as
7030 // "mismatched end tag: expected '</!DOC>', got '</oc [>'".
7031 use crate::entity_resolver::InMemoryResolver;
7032 use std::sync::Arc;
7033
7034 let resolver = Arc::new(
7035 InMemoryResolver::new()
7036 .with_system("file:///fake/foo.ent", b"<evil>XML</evil>".to_vec())
7037 );
7038 let opts = ParseOptions {
7039 external_resolver: Some(resolver),
7040 ..ParseOptions::default()
7041 };
7042 let src = br#"<!DOCTYPE doc [
7043 <!ENTITY foo SYSTEM "file:///fake/foo.ent">
7044 ]>
7045 <doc>&foo;</doc>"#;
7046 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src) }
7047 .with_options(opts);
7048
7049 // Count structurally — entity-stream-pushed start/end tags
7050 // expose empty name bytes through the byte reader (the
7051 // element_stack still tracks them by name internally, which
7052 // is what end-tag matching needs). We assert the shape
7053 // of the event stream: <doc> Start, then a Start from
7054 // inside the entity, a Text "XML", a balancing End for the
7055 // entity-pushed Start, then </doc> End.
7056 let mut starts = 0;
7057 let mut ends = 0;
7058 let mut texts: Vec<Vec<u8>> = Vec::new();
7059 loop {
7060 match r.next().expect("parse should succeed with markup-bearing entity") {
7061 BytesEvent::Eof => break,
7062 BytesEvent::StartElement(_) => starts += 1,
7063 BytesEvent::EndElement(_) => ends += 1,
7064 BytesEvent::Text(t) => texts.push(t.as_bytes().to_vec()),
7065 _ => {}
7066 }
7067 }
7068 assert_eq!(starts, 2, "expected <doc> and entity-pushed <evil> as Start events");
7069 assert_eq!(ends, 2, "expected balancing End events for both");
7070 assert!(texts.iter().any(|t| t == b"XML"),
7071 "expected `XML` Text event from inside the entity, got: {texts:?}");
7072 }
7073
7074 #[test]
7075 fn external_entity_without_resolver_errors_on_reference() {
7076 // Without a resolver (default), the SYSTEM-declared entity
7077 // is recorded but never loaded — referencing it errors as
7078 // "undefined entity" because the entities map doesn't
7079 // have it.
7080 let src = br#"<!DOCTYPE doc [
7081 <!ENTITY foo SYSTEM "file:///fake/foo.ent">
7082 ]>
7083 <doc>&foo;</doc>"#;
7084 let err = parse_all(std::str::from_utf8(src).unwrap(),
7085 ParseOptions::default()).unwrap_err();
7086 assert!(err.to_string().contains("undefined entity")
7087 || err.to_string().contains("foo"),
7088 "expected undefined-entity error, got: {err}");
7089 }
7090
7091 #[test]
7092 fn external_entity_resolver_refused_propagates_error() {
7093 // A *referenced* external general entity whose resolver refuses
7094 // → parser surfaces the failure as an error in strict mode.
7095 // (Loading is lazy per XML 1.0 § 4.4.3, so the entity must be
7096 // referenced for the resolver to run at all.)
7097 use crate::entity_resolver::InMemoryResolver;
7098 use std::sync::Arc;
7099 // Empty InMemoryResolver refuses every resolve.
7100 let opts = ParseOptions {
7101 external_resolver: Some(Arc::new(InMemoryResolver::new())),
7102 ..ParseOptions::default()
7103 };
7104 let src = br#"<!DOCTYPE doc [
7105 <!ENTITY foo SYSTEM "file:///fake/foo.ent">
7106 ]>
7107 <doc>&foo;</doc>"#;
7108 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src) }
7109 .with_options(opts);
7110 let mut hit_err = None;
7111 loop {
7112 match r.next() {
7113 Ok(BytesEvent::Eof) => break,
7114 Ok(_) => continue,
7115 Err(e) => { hit_err = Some(e); break; }
7116 }
7117 }
7118 let e = hit_err.expect("resolver-refused entity reference should error in strict mode");
7119 assert!(e.to_string().contains("resolver"),
7120 "expected resolver-related error, got: {e}");
7121 }
7122
7123 #[test]
7124 fn unreferenced_external_entity_is_not_loaded() {
7125 // XML 1.0 § 4.4.3 + XXE hardening: declaring an external general
7126 // entity must not, on its own, trigger the resolver — only a
7127 // reference does. An unreferenced entity whose resolver would
7128 // refuse (or whose target is missing) therefore parses cleanly.
7129 use crate::entity_resolver::InMemoryResolver;
7130 use std::sync::Arc;
7131 let opts = ParseOptions {
7132 external_resolver: Some(Arc::new(InMemoryResolver::new())),
7133 ..ParseOptions::default()
7134 };
7135 let src = br#"<!DOCTYPE doc [
7136 <!ENTITY foo SYSTEM "file:///fake/foo.ent">
7137 ]>
7138 <doc/>"#;
7139 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src) }
7140 .with_options(opts);
7141 loop {
7142 match r.next() {
7143 Ok(BytesEvent::Eof) => break,
7144 Ok(_) => continue,
7145 Err(e) => panic!("unreferenced external entity must not load: {e}"),
7146 }
7147 }
7148 assert!(r.recovered_errors().iter().all(|e| !e.message.contains("resolver")),
7149 "no resolver should run for an unreferenced external entity");
7150 }
7151
7152 /// Soundness regression: a safe-Rust `EntityResolver` impl that
7153 /// returns bytes that aren't valid UTF-8 must NOT trigger UB via
7154 /// `String::from_utf8_unchecked`. The parser must surface a
7155 /// clean error and keep the in-memory `String`s valid.
7156 ///
7157 /// The trait signature is `Result<Vec<u8>, _>` — nothing in the
7158 /// type system enforces UTF-8, and a perfectly safe impl (e.g.
7159 /// one that fetches bytes over the network and forgot to decode
7160 /// the charset) can produce non-UTF-8 input. The parser owns
7161 /// the validation, not the resolver author.
7162 #[test]
7163 fn external_entity_resolver_invalid_utf8_propagates_error() {
7164 use crate::entity_resolver::InMemoryResolver;
7165 use std::sync::Arc;
7166 // 0x80 is a UTF-8 continuation byte with no leading byte —
7167 // unambiguously invalid UTF-8 in any position.
7168 let resolver = Arc::new(
7169 InMemoryResolver::new()
7170 .with_system("file:///fake/foo.ent", vec![0x80, 0x80, 0x80])
7171 );
7172 let opts = ParseOptions {
7173 external_resolver: Some(resolver),
7174 ..ParseOptions::default()
7175 };
7176 let src = br#"<!DOCTYPE doc [
7177 <!ENTITY foo SYSTEM "file:///fake/foo.ent">
7178 ]>
7179 <doc>&foo;</doc>"#;
7180 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src) }
7181 .with_options(opts);
7182 let mut hit_err: Option<XmlError> = None;
7183 loop {
7184 match r.next() {
7185 Ok(BytesEvent::Eof) => break,
7186 Ok(_) => continue,
7187 Err(e) => { hit_err = Some(e); break; }
7188 }
7189 }
7190 let e = hit_err.expect(
7191 "resolver returning non-UTF-8 bytes must surface a parse error, \
7192 not silently UB-coerce into a String"
7193 );
7194 let msg = e.to_string();
7195 assert!(
7196 msg.to_lowercase().contains("utf-8") || msg.to_lowercase().contains("utf8"),
7197 "expected UTF-8-related error, got: {msg}"
7198 );
7199 }
7200
7201 #[test]
7202 fn external_entity_resolver_refused_recovered() {
7203 // Recovery mode: a *referenced* entity whose resolver refuses
7204 // logs the error and the parse continues (the reference expands
7205 // to nothing). Loading is lazy, so the entity is referenced
7206 // here to drive the resolver.
7207 use crate::entity_resolver::InMemoryResolver;
7208 use std::sync::Arc;
7209 let opts = ParseOptions {
7210 external_resolver: Some(Arc::new(InMemoryResolver::new())),
7211 recovery_mode: true,
7212 ..ParseOptions::default()
7213 };
7214 let src = br#"<!DOCTYPE doc [
7215 <!ENTITY foo SYSTEM "file:///fake/foo.ent">
7216 ]>
7217 <doc>&foo;</doc>"#;
7218 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src) }
7219 .with_options(opts);
7220 loop {
7221 match r.next().unwrap() {
7222 BytesEvent::Eof => break,
7223 _ => {}
7224 }
7225 }
7226 assert!(r.recovered_errors().iter().any(|e| e.message.contains("resolver")),
7227 "expected resolver error in recovered list");
7228 }
7229
7230 #[test]
7231 fn rejects_two_root_elements() {
7232 // XML 1.0 § 2.1 [document]: exactly one root.
7233 let err = parse_all("<a/><b/>", ParseOptions::default()).unwrap_err();
7234 assert!(err.to_string().contains("one root"),
7235 "expected 'one root' in error, got: {err}");
7236 }
7237
7238 #[test]
7239 fn rejects_text_at_document_level_before_root() {
7240 let err = parse_all("hello<r/>", ParseOptions::default()).unwrap_err();
7241 assert!(err.to_string().contains("document level"),
7242 "expected 'document level' in error, got: {err}");
7243 }
7244
7245 #[test]
7246 fn rejects_text_after_root() {
7247 let err = parse_all("<r/>trailing text", ParseOptions::default()).unwrap_err();
7248 assert!(err.to_string().contains("document level"),
7249 "expected 'document level' in error, got: {err}");
7250 }
7251
7252 #[test]
7253 fn allows_comments_and_pis_at_document_level() {
7254 // Misc (comment / PI / whitespace) is legal at the document
7255 // level both before and after the root element.
7256 assert!(parse_all("<!-- before --><r/><!-- after -->", ParseOptions::default()).is_ok());
7257 assert!(parse_all("<?pi ?><r/><?pi ?>", ParseOptions::default()).is_ok());
7258 assert!(parse_all(" <r/>\n", ParseOptions::default()).is_ok());
7259 }
7260
7261 #[test]
7262 fn rejects_empty_xml_declaration() {
7263 // XML 1.0 § 2.8 [XMLDecl]: VersionInfo is required.
7264 let err = parse_all("<?xml?><r/>", ParseOptions::default()).unwrap_err();
7265 assert!(err.to_string().contains("version") || err.to_string().contains("XMLDecl"),
7266 "expected 'version' / 'XMLDecl' in error, got: {err}");
7267 }
7268
7269 #[test]
7270 fn rejects_xml_decl_without_version() {
7271 let err = parse_all(r#"<?xml encoding="UTF-8"?><r/>"#, ParseOptions::default()).unwrap_err();
7272 assert!(err.to_string().contains("version"),
7273 "expected 'version' in error, got: {err}");
7274 }
7275
7276 #[test]
7277 fn accepts_full_xml_decl() {
7278 // Sanity: the canonical full declaration still parses.
7279 assert!(parse_all(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><r/>"#,
7280 ParseOptions::default()).is_ok());
7281 }
7282
7283 // ── opt-in: skip_inter_element_whitespace ────────────────────────
7284 //
7285 // Mirrors quick-xml's `trim_text(true)`. Default is off — we
7286 // emit every whitespace text event correctly. When enabled,
7287 // pure-whitespace runs between tags are dropped entirely.
7288
7289 #[test]
7290 fn skip_inter_element_whitespace_drops_indent_runs() {
7291 let src = "<root>\n <a/>\n <b/>\n</root>";
7292 let opts = ParseOptions {
7293 skip_inter_element_whitespace: true,
7294 ..ParseOptions::default()
7295 };
7296 let (total, ws) = count_text_events(src, opts);
7297 assert_eq!(ws, 0, "opt-in should suppress whitespace-only text");
7298 assert_eq!(total, 0, "no other text in this document");
7299 }
7300
7301 #[test]
7302 fn skip_inter_element_whitespace_keeps_non_blank_text_verbatim() {
7303 // `remove_blank_text` drops only *entirely* whitespace runs that
7304 // sit between elements; a run that is the leading whitespace of a
7305 // non-blank text node is kept verbatim (libxml2 never strips
7306 // prose). So both "foo " and " baz" survive intact — only the
7307 // run's not being all-whitespace-between-elements matters.
7308 let src = "<p>foo <b>bar</b> baz</p>";
7309 let opts = ParseOptions {
7310 skip_inter_element_whitespace: true,
7311 ..ParseOptions::default()
7312 };
7313 let mut r = unsafe { XmlBytesReader::from_bytes_unchecked(src.as_bytes()) }
7314 .with_options(opts);
7315 let mut texts = Vec::new();
7316 loop {
7317 match r.next().unwrap() {
7318 BytesEvent::Eof => break,
7319 BytesEvent::Text(t) => texts.push(t.as_bytes().to_vec()),
7320 _ => {}
7321 }
7322 }
7323 assert_eq!(
7324 texts.iter().map(|t| String::from_utf8_lossy(t).into_owned()).collect::<Vec<_>>(),
7325 vec!["foo ".to_string(), "bar".to_string(), " baz".to_string()],
7326 );
7327 }
7328
7329 /// Lazy contract: discarding `BytesEvent::StartElement(_)` should
7330 /// not extract the name or construct a Scanner. We can't measure
7331 /// allocations from a unit test cheaply, but we *can* verify the
7332 /// event simply exists with no panics, no out-of-band work, and
7333 /// `next()` advances correctly afterward.
7334 #[test]
7335 fn lazy_discard_event_advances_correctly() {
7336 // Wrapped in a root element — XML 1.0 § 2.1 forbids multiple
7337 // top-level elements, and the reader now enforces that. The
7338 // test's intent (verify lazy discard advances correctly) is
7339 // unchanged.
7340 let mut r = XmlBytesReader::from_str("<root><a/><b/><c/></root>");
7341 // Consume the root opener.
7342 match r.next().unwrap() {
7343 BytesEvent::StartElement(_) => {}
7344 other => panic!("expected root StartElement, got {other:?}"),
7345 }
7346 // Discard each StartElement entirely (don't even bind tag).
7347 for expected in [b"a", b"b", b"c"] {
7348 match r.next().unwrap() {
7349 BytesEvent::StartElement(_) => {} // discard
7350 other => panic!("expected StartElement, got {other:?}"),
7351 }
7352 match r.next().unwrap() {
7353 BytesEvent::EndElement(tag) => assert_eq!(tag.name(), &expected[..]),
7354 other => panic!("expected EndElement, got {other:?}"),
7355 }
7356 }
7357 // Consume the wrapping `</root>` then EOF.
7358 match r.next().unwrap() {
7359 BytesEvent::EndElement(tag) => assert_eq!(tag.name(), &b"root"[..]),
7360 other => panic!("expected root EndElement, got {other:?}"),
7361 }
7362 assert!(matches!(r.next().unwrap(), BytesEvent::Eof));
7363 }
7364
7365 // ── Debug impls on all event types ────────────────────────────
7366
7367 #[test]
7368 fn debug_impls_for_event_payloads() {
7369 let mut r = XmlBytesReader::from_str(
7370 r#"<root attr="v"><![CDATA[cd]]>text<!-- c --><?pi data?></root>"#,
7371 );
7372
7373 // StartElement
7374 match r.next().unwrap() {
7375 BytesEvent::StartElement(t) => {
7376 let s = format!("{t:?}");
7377 assert!(s.contains("BytesStartTag"), "got {s}");
7378 assert!(s.contains("root"), "got {s}");
7379 }
7380 _ => panic!(),
7381 }
7382 // CData
7383 match r.next().unwrap() {
7384 BytesEvent::CData(t) => {
7385 let s = format!("{t:?}");
7386 assert!(s.contains("BytesCData"), "got {s}");
7387 assert!(s.contains("cd"), "got {s}");
7388 }
7389 _ => panic!(),
7390 }
7391 // Text
7392 match r.next().unwrap() {
7393 BytesEvent::Text(t) => {
7394 let s = format!("{t:?}");
7395 assert!(s.contains("BytesText"), "got {s}");
7396 assert!(s.contains("text"), "got {s}");
7397 }
7398 _ => panic!(),
7399 }
7400 // Comment
7401 match r.next().unwrap() {
7402 BytesEvent::Comment(t) => {
7403 let s = format!("{t:?}");
7404 assert!(s.contains("BytesComment"), "got {s}");
7405 assert!(s.contains(" c "), "got {s}");
7406 }
7407 _ => panic!(),
7408 }
7409 // Pi
7410 match r.next().unwrap() {
7411 BytesEvent::Pi(t) => {
7412 let s = format!("{t:?}");
7413 assert!(s.contains("BytesPi"), "got {s}");
7414 assert!(s.contains("pi"), "got {s}");
7415 assert!(s.contains("data"), "got {s}");
7416 }
7417 _ => panic!(),
7418 }
7419 // EndElement
7420 match r.next().unwrap() {
7421 BytesEvent::EndElement(t) => {
7422 let s = format!("{t:?}");
7423 assert!(s.contains("BytesEndTag"), "got {s}");
7424 assert!(s.contains("root"), "got {s}");
7425 }
7426 _ => panic!(),
7427 }
7428 }
7429
7430 #[test]
7431 fn debug_impl_for_entity_ref() {
7432 let src = r#"<?xml version="1.0"?>
7433<!DOCTYPE r [<!ENTITY foo "bar">]>
7434<r>&foo;</r>"#;
7435 let opts = crate::options::ParseOptions {
7436 resolve_entities: false,
7437 ..crate::options::ParseOptions::default()
7438 };
7439 let mut r = XmlBytesReader::from_str(src).with_options(opts);
7440 loop {
7441 match r.next().unwrap() {
7442 BytesEvent::EntityRef(e) => {
7443 // BytesEntityRef::name accessor.
7444 assert_eq!(e.name(), b"foo");
7445 let s = format!("{e:?}");
7446 assert!(s.contains("BytesEntityRef"), "got {s}");
7447 assert!(s.contains("foo"), "got {s}");
7448 break;
7449 }
7450 BytesEvent::Eof => panic!("EntityRef not seen"),
7451 _ => continue,
7452 }
7453 }
7454 }
7455
7456 #[test]
7457 fn start_tag_name_inside_entity_replacement_stream_is_captured() {
7458 // An entity whose replacement text contains an element start
7459 // tag. When the parser expands `&inner;` and emits the
7460 // StartElement event, `name()` used to return `&[]` because
7461 // the source-offsets indexed into the entity-stream buffer,
7462 // not the document source. Now the name is captured into
7463 // an owned slot.
7464 let src = r#"<?xml version="1.0"?>
7465<!DOCTYPE r [<!ENTITY inner "<span/>">]>
7466<r>&inner;</r>"#;
7467 let mut r = XmlBytesReader::from_str(src);
7468 let mut saw_span = false;
7469 loop {
7470 match r.next().unwrap() {
7471 BytesEvent::StartElement(tag) => {
7472 if tag.name() == b"span" {
7473 saw_span = true;
7474 // name_cow returns Cow::Owned for entity-stream
7475 // names (no source slice to borrow from).
7476 assert!(matches!(tag.name_cow(), Cow::Owned(_)),
7477 "entity-stream start tag must carry an owned name");
7478 }
7479 }
7480 BytesEvent::Eof => break,
7481 _ => continue,
7482 }
7483 }
7484 assert!(saw_span, "<span/> from inside &inner; was never emitted");
7485 }
7486
7487 #[test]
7488 fn start_tag_name_on_original_source_stays_borrowed() {
7489 // Sanity check: the common path still returns Cow::Borrowed
7490 // (no allocation when the name lives in the source buffer).
7491 let mut r = XmlBytesReader::from_str("<el/>");
7492 match r.next().unwrap() {
7493 BytesEvent::StartElement(tag) => {
7494 assert_eq!(tag.name(), b"el");
7495 assert!(matches!(tag.name_cow(), Cow::Borrowed(_)),
7496 "source-borrowed start tag must avoid the heap copy");
7497 }
7498 _ => panic!(),
7499 }
7500 }
7501
7502 // ── BytesAttr::name accessor ──────────────────────────────────
7503
7504 #[test]
7505 fn bytes_attr_name_accessor() {
7506 let src = r#"<el id="1" class="x"/>"#;
7507 let mut r = XmlBytesReader::from_str(src);
7508 match r.next().unwrap() {
7509 BytesEvent::StartElement(tag) => {
7510 let attrs: Vec<_> = tag.attrs().map(|a| a.unwrap()).collect();
7511 // .name() method (vs .name field).
7512 assert_eq!(attrs[0].name(), b"id");
7513 assert_eq!(attrs[1].name(), b"class");
7514 }
7515 _ => panic!(),
7516 }
7517 }
7518
7519 // ── unescape_bytes function ───────────────────────────────────
7520
7521 #[test]
7522 fn unescape_bytes_no_amp_returns_borrowed() {
7523 let out = unescape_bytes(b"no entities here");
7524 assert!(matches!(out, Cow::Borrowed(_)));
7525 assert_eq!(&*out, b"no entities here");
7526 }
7527
7528 #[test]
7529 fn unescape_bytes_predefined() {
7530 assert_eq!(&*unescape_bytes(b"&"), b"&");
7531 assert_eq!(&*unescape_bytes(b"<"), b"<");
7532 assert_eq!(&*unescape_bytes(b">"), b">");
7533 assert_eq!(&*unescape_bytes(b"""), b"\"");
7534 assert_eq!(&*unescape_bytes(b"'"), b"'");
7535 }
7536
7537 #[test]
7538 fn unescape_bytes_numeric_decimal_and_hex() {
7539 assert_eq!(&*unescape_bytes(b"A"), b"A");
7540 assert_eq!(&*unescape_bytes(b"A"), b"A");
7541 assert_eq!(&*unescape_bytes(b"A"), b"A");
7542 // Multi-byte UTF-8 codepoint.
7543 let euro = unescape_bytes(b"€");
7544 assert_eq!(&*euro, "€".as_bytes());
7545 }
7546
7547 #[test]
7548 fn probe_p68_standalone_yes_dom_path_rejects() {
7549 // ibm68n06 shape: standalone="yes", entity declared in external
7550 // subset, referenced in body. XML 1.0 § 4.1 WFC: Entity Declared.
7551 use crate::parser::parse_bytes;
7552 use crate::options::ParseOptions;
7553 use std::sync::Arc;
7554 use std::collections::HashMap;
7555 use crate::entity_resolver::{EntityResolver, ResolveError};
7556 #[derive(Debug)] struct Stub { served: HashMap<String, Vec<u8>> }
7557 impl EntityResolver for Stub {
7558 fn resolve(&self, _: Option<&str>, sid: &str, _: Option<&str>)
7559 -> std::result::Result<Vec<u8>, ResolveError>
7560 {
7561 self.served.get(sid).cloned().ok_or_else(|| ResolveError::Io(format!("no: {sid}")))
7562 }
7563 }
7564 let mut served = HashMap::new();
7565 served.insert(
7566 "file:///x/ibm.dtd".to_string(),
7567 br#"<!ENTITY aaa "aString">"#.to_vec(),
7568 );
7569 let opts = ParseOptions {
7570 load_external_dtd: true,
7571 base_url: Some("file:///x/ibm.xml".to_string()),
7572 external_resolver: Some(Arc::new(Stub { served }) as Arc<dyn EntityResolver>),
7573 ..ParseOptions::default()
7574 };
7575 let src = br#"<?xml version="1.0" standalone="yes"?>
7576<!DOCTYPE root SYSTEM "ibm.dtd" [<!ELEMENT root (#PCDATA)><!ATTLIST root att CDATA #IMPLIED>]>
7577<root att="&aaa;">x</root>"#;
7578 let res = parse_bytes(src, &opts);
7579 assert!(res.is_err(),
7580 "standalone=yes + external entity decl must be rejected; got {:?}", res);
7581 }
7582
7583 #[test]
7584 fn probe_p66_sax_path_must_also_reject() {
7585 // Same fixture, but driven through XmlBytesReader::next() —
7586 // the SAX/event path the bench harness uses. Today this
7587 // silently accepts because we never iterate the attribute
7588 // values, so scan_att_value_cow doesn't run.
7589 //
7590 // The contract of "Eof reached without error implies the
7591 // document is well-formed" requires us to validate
7592 // attributes eagerly here.
7593 let src = br#"<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r att CDATA #IMPLIED>]><r att="�"/>"#;
7594 let opts = crate::options::ParseOptions {
7595 load_external_dtd: false,
7596 ..crate::options::ParseOptions::default()
7597 };
7598 let mut r = XmlBytesReader::from_bytes(src)
7599 .expect("UTF-8 ok")
7600 .with_options(opts);
7601 let mut hit_error = false;
7602 loop {
7603 match r.next() {
7604 Ok(BytesEvent::Eof) => break,
7605 Ok(_) => continue,
7606 Err(_) => { hit_error = true; break; }
7607 }
7608 }
7609 assert!(hit_error, "SAX path must surface invalid Char in attr value");
7610 }
7611
7612 #[test]
7613 fn probe_p66_attr_charref_to_u0000() {
7614 // ibm66n12 shape: char ref to U+0000 in an attribute value.
7615 // XML 1.0 § 4.1 requires this to be NOT-WF.
7616 use crate::parser::parse_bytes;
7617 use crate::options::ParseOptions;
7618 let src = br#"<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r att CDATA #IMPLIED>]><r att="�"/>"#;
7619 let res = parse_bytes(src, &ParseOptions::default());
7620 assert!(res.is_err(), "U+0000 char ref in attr value must be rejected; got Ok");
7621 }
7622
7623 #[test]
7624 fn probe_p66_attr_charref_to_ufffe() {
7625 use crate::parser::parse_bytes;
7626 use crate::options::ParseOptions;
7627 let src = br#"<!DOCTYPE r [<!ELEMENT r EMPTY><!ATTLIST r att CDATA #IMPLIED>]><r att=""/>"#;
7628 let res = parse_bytes(src, &ParseOptions::default());
7629 assert!(res.is_err(), "U+FFFE char ref in attr value must be rejected; got Ok");
7630 }
7631
7632 #[test]
7633 fn unescape_bytes_invalid_charref_left_literal() {
7634 // Unparseable codepoint → keep literal.
7635 assert_eq!(&*unescape_bytes(b"&#abc;"), b"&#abc;");
7636 // Out-of-range codepoint → keep literal.
7637 assert_eq!(&*unescape_bytes(b"�"), b"�");
7638 }
7639
7640 #[test]
7641 fn unescape_bytes_unknown_entity_left_literal() {
7642 assert_eq!(&*unescape_bytes(b"&bogus;"), b"&bogus;");
7643 }
7644
7645 #[test]
7646 fn unescape_bytes_ampersand_without_semicolon_left_literal() {
7647 assert_eq!(&*unescape_bytes(b"& not an entity"), b"& not an entity");
7648 // Semicolon further than 16 bytes away → not treated as entity.
7649 let s = b"&very_long_pseudo_entity_name_that_is_too_far_away;";
7650 assert_eq!(&*unescape_bytes(s), s);
7651 }
7652
7653 #[test]
7654 fn unescape_bytes_mixed_content() {
7655 let out = unescape_bytes(b"a & b < c > d");
7656 assert_eq!(&*out, b"a & b < c > d");
7657 }
7658
7659 // ── resolve_uri ───────────────────────────────────────────────
7660
7661 #[test]
7662 fn resolve_uri_absolute_file_url_passes_through() {
7663 // Already-absolute `file://` URL stays untouched, regardless of base.
7664 assert_eq!(
7665 resolve_uri("file:///abs/path/foo.xml", Some("file:///other/base.xml")),
7666 "file:///abs/path/foo.xml"
7667 );
7668 }
7669
7670 #[test]
7671 fn resolve_uri_absolute_path_passes_through() {
7672 // Leading `/` is treated as absolute even without a `file://` scheme.
7673 assert_eq!(
7674 resolve_uri("/abs/foo.xml", Some("file:///base/dir.xml")),
7675 "/abs/foo.xml"
7676 );
7677 }
7678
7679 #[test]
7680 fn resolve_uri_http_url_passes_through() {
7681 // Any URI scheme is considered absolute — no joining.
7682 assert_eq!(
7683 resolve_uri("http://example.com/x.dtd", Some("file:///doc.xml")),
7684 "http://example.com/x.dtd"
7685 );
7686 }
7687
7688 #[test]
7689 fn resolve_uri_relative_joined_against_parent_of_base() {
7690 // Relative path is joined against the *parent directory* of base
7691 // (matching how XML 1.0 § 4.2.2 defines base URI semantics).
7692 assert_eq!(
7693 resolve_uri("rel/path/foo.ent", Some("file:///docs/E18.xml")),
7694 "file:///docs/rel/path/foo.ent"
7695 );
7696 }
7697
7698 #[test]
7699 fn resolve_uri_dotdot_components_preserved_for_resolver() {
7700 // `..` segments are *not* resolved here — the resolver's
7701 // canonicalize() step handles them, which keeps the
7702 // security check (starts_with(root)) accurate.
7703 let r = resolve_uri("../sib/foo.ent", Some("file:///docs/sub/E18.xml"));
7704 assert_eq!(r, "file:///docs/sub/../sib/foo.ent");
7705 }
7706
7707 #[test]
7708 fn resolve_uri_no_base_passes_through() {
7709 // No base → return verbatim; the resolver will decide what
7710 // to do with a relative path (often: refuse).
7711 assert_eq!(resolve_uri("rel/foo.ent", None), "rel/foo.ent");
7712 }
7713
7714 #[test]
7715 fn resolve_uri_non_file_base_preserved_without_scheme() {
7716 // No `file://` scheme on base means the result also has no
7717 // scheme — joining is purely path-level.
7718 assert_eq!(
7719 resolve_uri("foo.ent", Some("/abs/docs/E18.xml")),
7720 "/abs/docs/foo.ent"
7721 );
7722 }
7723
7724 // ── End-to-end: parser pre-resolves URLs per E18 rule ────────
7725 //
7726 // Verifies the parser hands resolver an *absolute* system_id
7727 // computed per XML 1.0 errata E18: PE decls use containing
7728 // entity's base URI; general-entity decls use the document URL.
7729
7730 #[test]
7731 fn parser_e18_pe_decl_uses_containing_entity_base_uri() {
7732 use crate::entity_resolver::{EntityResolver, ResolveError};
7733 use std::sync::{Arc, Mutex};
7734
7735 // Recording resolver: captures every URL the parser asks
7736 // for, and serves canned bytes from an in-memory map keyed
7737 // by the absolute URL.
7738 #[derive(Debug, Default)]
7739 struct Recorder {
7740 asked: Mutex<Vec<String>>,
7741 served: std::collections::HashMap<String, Vec<u8>>,
7742 }
7743 impl EntityResolver for Recorder {
7744 fn resolve(
7745 &self,
7746 _public_id: Option<&str>,
7747 system_id: &str,
7748 _base_uri: Option<&str>,
7749 ) -> std::result::Result<Vec<u8>, ResolveError> {
7750 self.asked.lock().unwrap().push(system_id.to_string());
7751 self.served.get(system_id).cloned().ok_or_else(|| {
7752 ResolveError::Io(format!("no fixture for {system_id}"))
7753 })
7754 }
7755 }
7756
7757 let mut served = std::collections::HashMap::new();
7758 // PE `outer` declares `<!ENTITY % inner SYSTEM "sib.ent">`.
7759 // The relative `sib.ent` must resolve against `outer`'s URL
7760 // → `file:///docs/sub/sib.ent`, NOT against the document
7761 // URL (which would give `file:///docs/sib.ent`).
7762 served.insert(
7763 "file:///docs/sub/outer.ent".to_string(),
7764 b"<!ENTITY % inner SYSTEM 'sib.ent'>".to_vec(),
7765 );
7766 served.insert(
7767 "file:///docs/sub/sib.ent".to_string(),
7768 b"<!-- inner -->".to_vec(),
7769 );
7770
7771 let recorder = Arc::new(Recorder { asked: Mutex::new(Vec::new()), served });
7772 let opts = ParseOptions {
7773 base_url: Some("file:///docs/E18.xml".to_string()),
7774 external_resolver: Some(recorder.clone() as Arc<dyn EntityResolver>),
7775 // External parameter-entity loading is gated behind this opt-in
7776 // (XXE/SSRF hardening); these tests exercise that loading to
7777 // verify E18 base-URI resolution, so they enable it explicitly.
7778 load_external_dtd: true,
7779 ..ParseOptions::default()
7780 };
7781 // Declare the outer PE in the doc; %outer; then expands and
7782 // its `<!ENTITY % inner SYSTEM "sib.ent">` is parsed.
7783 let src = r#"<?xml version="1.0"?>
7784<!DOCTYPE r [
7785<!ENTITY % outer SYSTEM "sub/outer.ent">
7786%outer;
7787%inner;
7788]>
7789<r/>"#;
7790 let mut r = XmlBytesReader::from_str(src).with_options(opts);
7791 loop {
7792 match r.next().unwrap() {
7793 BytesEvent::Eof => break,
7794 _ => {}
7795 }
7796 }
7797 let asked = recorder.asked.lock().unwrap().clone();
7798 // Two URLs requested in order:
7799 // 1. `sub/outer.ent` resolved against the doc URL
7800 // 2. `sib.ent` resolved against the *outer* PE's URL
7801 assert_eq!(asked, vec![
7802 "file:///docs/sub/outer.ent".to_string(),
7803 "file:///docs/sub/sib.ent".to_string(),
7804 ]);
7805 }
7806
7807 #[test]
7808 fn parser_e18_general_entity_decl_uses_document_base_uri() {
7809 // The E18 rule's punchline: a general-entity decl inside a
7810 // deeply-nested external PE resolves its SYSTEM URL against
7811 // the *document* URL, not the containing PE. This is the
7812 // unusual rule rmt-e2e-18 was designed to catch.
7813 use crate::entity_resolver::{EntityResolver, ResolveError};
7814 use std::sync::{Arc, Mutex};
7815
7816 #[derive(Debug, Default)]
7817 struct Recorder {
7818 asked: Mutex<Vec<String>>,
7819 served: std::collections::HashMap<String, Vec<u8>>,
7820 }
7821 impl EntityResolver for Recorder {
7822 fn resolve(
7823 &self,
7824 _public_id: Option<&str>,
7825 system_id: &str,
7826 _base_uri: Option<&str>,
7827 ) -> std::result::Result<Vec<u8>, ResolveError> {
7828 self.asked.lock().unwrap().push(system_id.to_string());
7829 self.served.get(system_id).cloned().ok_or_else(|| {
7830 ResolveError::Io(format!("no fixture for {system_id}"))
7831 })
7832 }
7833 }
7834
7835 let mut served = std::collections::HashMap::new();
7836 // Doc: file:///docs/E18.xml
7837 // %outer; bytes live at file:///docs/sub/outer.ent.
7838 // Inside outer, a general-entity decl `ent SYSTEM 'E18-ent'`.
7839 // Per E18: `E18-ent` resolves against the *document* URL →
7840 // `file:///docs/E18-ent`, NOT the containing PE's URL
7841 // (which would give `file:///docs/sub/E18-ent`).
7842 served.insert(
7843 "file:///docs/sub/outer.ent".to_string(),
7844 b"<!ENTITY ent SYSTEM 'E18-ent'>".to_vec(),
7845 );
7846 served.insert(
7847 "file:///docs/E18-ent".to_string(),
7848 b"main-dir-content".to_vec(),
7849 );
7850
7851 let recorder = Arc::new(Recorder { asked: Mutex::new(Vec::new()), served });
7852 let opts = ParseOptions {
7853 base_url: Some("file:///docs/E18.xml".to_string()),
7854 external_resolver: Some(recorder.clone() as Arc<dyn EntityResolver>),
7855 // External parameter-entity loading is gated behind this opt-in
7856 // (XXE/SSRF hardening); these tests exercise that loading to
7857 // verify E18 base-URI resolution, so they enable it explicitly.
7858 load_external_dtd: true,
7859 ..ParseOptions::default()
7860 };
7861 let src = r#"<?xml version="1.0"?>
7862<!DOCTYPE r [
7863<!ENTITY % outer SYSTEM "sub/outer.ent">
7864%outer;
7865]>
7866<r>&ent;</r>"#;
7867 let mut r = XmlBytesReader::from_str(src).with_options(opts);
7868 loop {
7869 match r.next().unwrap() {
7870 BytesEvent::Eof => break,
7871 _ => {}
7872 }
7873 }
7874 let asked = recorder.asked.lock().unwrap().clone();
7875 // Crucially: `E18-ent` was resolved against `/docs/`, not
7876 // `/docs/sub/`. This is the E18 rule.
7877 assert_eq!(asked, vec![
7878 "file:///docs/sub/outer.ent".to_string(),
7879 "file:///docs/E18-ent".to_string(),
7880 ]);
7881 }
7882
7883 // ── WFC: Entity Declared carve-out ───────────────────────────
7884 //
7885 // XML 1.0 § 4.1: "for an entity reference that does NOT occur
7886 // within the external subset or a parameter entity, the Name
7887 // given in the entity reference MUST match that of an entity
7888 // declared." The contrapositive — refs inside external content
7889 // are exempt from the WF rule. These tests cover the carve-out
7890 // for general entities, parameter entities, and ATTLIST default
7891 // values (each goes through a different code path).
7892
7893 #[test]
7894 fn carve_out_undeclared_pe_inside_external_pe_accepted() {
7895 use crate::entity_resolver::{EntityResolver, ResolveError};
7896 use std::sync::{Arc, Mutex};
7897 use std::collections::HashMap;
7898
7899 #[derive(Debug)]
7900 struct Stub { served: HashMap<String, Vec<u8>> }
7901 impl EntityResolver for Stub {
7902 fn resolve(&self, _: Option<&str>, sid: &str, _: Option<&str>)
7903 -> std::result::Result<Vec<u8>, ResolveError>
7904 {
7905 self.served.get(sid).cloned().ok_or_else(|| {
7906 ResolveError::Io(format!("no fixture: {sid}"))
7907 })
7908 }
7909 }
7910 let mut served = HashMap::new();
7911 // Outer PE's content references an undeclared `%pe_missing;`.
7912 // Per the WFC carve-out (ref is inside external PE), parse
7913 // should accept; expansion is skipped with a warning logged.
7914 served.insert(
7915 "file:///docs/outer.ent".to_string(),
7916 b"<!ELEMENT root EMPTY> %pe_missing;".to_vec(),
7917 );
7918 let opts = ParseOptions {
7919 base_url: Some("file:///docs/doc.xml".to_string()),
7920 external_resolver: Some(Arc::new(Stub { served }) as Arc<dyn EntityResolver>),
7921 ..ParseOptions::default()
7922 };
7923 let src = r#"<?xml version="1.0"?>
7924<!DOCTYPE root [
7925<!ENTITY % outer SYSTEM "outer.ent">
7926%outer;
7927]>
7928<root/>"#;
7929 let mut r = XmlBytesReader::from_str(src).with_options(opts);
7930 let _ = Mutex::new(()); // silence unused-import in some builds
7931 loop {
7932 match r.next().expect("WFC carve-out should let parse succeed") {
7933 BytesEvent::Eof => break,
7934 _ => {}
7935 }
7936 }
7937 }
7938
7939 #[test]
7940 fn carve_out_undeclared_ge_inside_external_pe_accepted() {
7941 // ATTLIST default value contains `&ge_missing;` (general
7942 // entity). The ATTLIST is parsed inside the external PE's
7943 // content, so the carve-out applies — non-validating parse
7944 // accepts. This is the ibm-invalid-P68-i03/i04 shape.
7945 use crate::entity_resolver::{EntityResolver, ResolveError};
7946 use std::sync::Arc;
7947 use std::collections::HashMap;
7948
7949 #[derive(Debug)]
7950 struct Stub { served: HashMap<String, Vec<u8>> }
7951 impl EntityResolver for Stub {
7952 fn resolve(&self, _: Option<&str>, sid: &str, _: Option<&str>)
7953 -> std::result::Result<Vec<u8>, ResolveError>
7954 {
7955 self.served.get(sid).cloned().ok_or_else(|| {
7956 ResolveError::Io(format!("no fixture: {sid}"))
7957 })
7958 }
7959 }
7960 let mut served = HashMap::new();
7961 served.insert(
7962 "file:///docs/outer.ent".to_string(),
7963 br#"<!ATTLIST root attr CDATA "&ge_missing;">"#.to_vec(),
7964 );
7965 let opts = ParseOptions {
7966 base_url: Some("file:///docs/doc.xml".to_string()),
7967 external_resolver: Some(Arc::new(Stub { served }) as Arc<dyn EntityResolver>),
7968 ..ParseOptions::default()
7969 };
7970 let src = r#"<?xml version="1.0"?>
7971<!DOCTYPE root [
7972<!ELEMENT root EMPTY>
7973<!ENTITY % outer SYSTEM "outer.ent">
7974%outer;
7975]>
7976<root/>"#;
7977 let mut r = XmlBytesReader::from_str(src).with_options(opts);
7978 loop {
7979 match r.next().expect("WFC carve-out should let parse succeed") {
7980 BytesEvent::Eof => break,
7981 _ => {}
7982 }
7983 }
7984 }
7985
7986 #[test]
7987 fn undeclared_ge_in_original_source_still_rejected() {
7988 // Negative control: WFC: Entity Declared still applies for
7989 // refs *outside* external content (regular doc body refs).
7990 // Otherwise the carve-out would have silently weakened the
7991 // rule for everyone, which would be a regression.
7992 let src = r#"<?xml version="1.0"?>
7993<!DOCTYPE root [
7994<!ELEMENT root (#PCDATA)>
7995]>
7996<root>&undeclared;</root>"#;
7997 let opts = ParseOptions::default();
7998 let mut r = XmlBytesReader::from_str(src).with_options(opts);
7999 let mut got_err = false;
8000 loop {
8001 match r.next() {
8002 Ok(BytesEvent::Eof) => break,
8003 Ok(_) => {}
8004 Err(e) => {
8005 assert!(
8006 e.message.contains("undefined entity") &&
8007 e.message.contains("WFC: Entity Declared"),
8008 "expected WFC: Entity Declared error, got: {}", e.message
8009 );
8010 got_err = true;
8011 break;
8012 }
8013 }
8014 }
8015 assert!(got_err, "expected undeclared-entity error outside external content");
8016 }
8017
8018 #[test]
8019 fn undeclared_pe_in_internal_subset_still_rejected() {
8020 // Negative control for the PE path: a PE reference at the
8021 // top of the doc's internal subset is NOT inside external
8022 // content, so the carve-out does not apply.
8023 let src = r#"<?xml version="1.0"?>
8024<!DOCTYPE root [
8025%missing_pe;
8026<!ELEMENT root EMPTY>
8027]>
8028<root/>"#;
8029 let opts = ParseOptions::default();
8030 let mut r = XmlBytesReader::from_str(src).with_options(opts);
8031 let mut got_err = false;
8032 loop {
8033 match r.next() {
8034 Ok(BytesEvent::Eof) => break,
8035 Ok(_) => {}
8036 Err(e) => {
8037 assert!(
8038 e.message.contains("undefined parameter entity"),
8039 "expected undefined-PE error, got: {}", e.message
8040 );
8041 got_err = true;
8042 break;
8043 }
8044 }
8045 }
8046 assert!(got_err, "expected undeclared-PE error outside external content");
8047 }
8048}