Skip to main content

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 `&amp;`
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(&notation_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    /// (`&#65;` / `&#x41;`) 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 (`&amp;` → `&`, etc.) and
4772        // numeric char refs (`&#xC9;` → `É`) 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 `&#38;` 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 "&#38;"> <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 `&#xA;` 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 (`&amp;`, `&lt;`,
5900/// `&gt;`, `&quot;`, `&apos;`) 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. `&#x4E2D;`) 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&amp;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        // `&amp;` 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"&amp;"),  b"&");
7531        assert_eq!(&*unescape_bytes(b"&lt;"),   b"<");
7532        assert_eq!(&*unescape_bytes(b"&gt;"),   b">");
7533        assert_eq!(&*unescape_bytes(b"&quot;"), b"\"");
7534        assert_eq!(&*unescape_bytes(b"&apos;"), b"'");
7535    }
7536
7537    #[test]
7538    fn unescape_bytes_numeric_decimal_and_hex() {
7539        assert_eq!(&*unescape_bytes(b"&#65;"),   b"A");
7540        assert_eq!(&*unescape_bytes(b"&#x41;"),  b"A");
7541        assert_eq!(&*unescape_bytes(b"&#X41;"),  b"A");
7542        // Multi-byte UTF-8 codepoint.
7543        let euro = unescape_bytes(b"&#8364;");
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="&#x0000;"/>"#;
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="&#x0000;"/>"#;
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="&#xfffe;"/>"#;
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"&#99999999;"), b"&#99999999;");
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 &amp; b &lt; c &gt; 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}