@@ -26,7 +26,7 @@
//!
//! ## Quick Start
//!
-//! ```
+//! ```text,xml-sec-example
//! use xmloxide::Document;
//!
//! let doc = Document::parse_str("<root><child>Hello</child></root>").unwrap();
@@ -34,28 +34,18 @@
//! assert_eq!(doc.node_name(root), Some("root"));
//! ```
-#[cfg(feature = "async")]
-pub mod async_xml;
-pub mod catalog;
-pub mod css;
pub mod encoding;
pub mod error;
-#[cfg(feature = "ffi")]
-pub mod ffi;
-pub mod html;
-pub mod html5;
pub mod parser;
-pub mod reader;
-pub mod sax;
-#[cfg(feature = "serde")]
-pub mod serde_xml;
+// Only upstream parser regression tests use this serializer. Production
+// canonicalization stays in xml-sec's existing C14N implementation.
+#[cfg(test)]
pub mod serial;
pub mod tree;
-#[allow(dead_code)]
-pub(crate) mod util;
pub mod validation;
-pub mod xinclude;
-pub mod xpath;
+
+#[cfg(test)]
+mod documentation_tests;
// Re-export primary types at the crate root for convenience.
pub use tree::{Attribute, Document, NodeId};
@@ -13,16 +13,17 @@
pub(crate) mod input;
pub mod push;
mod xml;
+pub(crate) use xml::XmlParser;
pub use push::PushParser;
use std::sync::Arc;
-use crate::error::ParseError;
-use crate::tree::Document;
+use crate::parser_xmloxide::error::ParseError;
+use crate::parser_xmloxide::tree::Document;
use input::{
- DEFAULT_MAX_ATTRIBUTES, DEFAULT_MAX_ATTRIBUTE_LENGTH, DEFAULT_MAX_DEPTH,
+ DEFAULT_MAX_ATTRIBUTE_LENGTH, DEFAULT_MAX_ATTRIBUTES, DEFAULT_MAX_DEPTH,
DEFAULT_MAX_ENTITY_EXPANSIONS, DEFAULT_MAX_NAME_LENGTH, DEFAULT_MAX_TEXT_LENGTH,
};
@@ -57,7 +58,7 @@
///
/// Use the builder pattern to configure options:
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::parser::ParseOptions;
///
/// let opts = ParseOptions::default()
@@ -238,6 +239,6 @@
/// Returns `ParseError` if the input is not well-formed XML and recovery
/// mode is not enabled.
pub fn parse_str_with_options(input: &str, options: &ParseOptions) -> Result<Document, ParseError> {
- let mut parser = xml::XmlParser::new(input, options);
+ let parser = xml::XmlParser::new(input, options)?;
parser.parse()
}
@@ -19,10 +19,91 @@
//!
//! No external entity loading is performed (immune to XXE).
-use std::collections::HashMap;
-
-use crate::error::{ErrorSeverity, ParseDiagnostic, ParseError, SourceLocation};
-use crate::parser::{EntityResolver, ExternalEntityRequest};
+use crate::xml::dom::storage::Values;
+use crate::xml::dom::storage::names::{NameStorage, Names};
+use crate::xml::dom::storage::{Native, Storage};
+
+use crate::parser_xmloxide::error::{ErrorSeverity, ParseDiagnostic, SourceLocation};
+use crate::parser_xmloxide::parser::{EntityResolver, ExternalEntityRequest};
+use crate::parser_xmloxide::validation::dtd::{EntityDecl, EntityKind};
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ReferenceContext {
+ Content,
+ Attribute,
+}
+
+#[derive(Clone, Copy)]
+enum NameTokenKind {
+ Name,
+ NmToken,
+}
+
+enum Replacement<'a, S: Storage> {
+ Borrowed(&'a str),
+ Decoded(S::Text),
+ External(String),
+}
+
+impl<S: Storage> AsRef<str> for Replacement<'_, S> {
+ fn as_ref(&self) -> &str {
+ match self {
+ Self::Borrowed(value) => value,
+ Self::Decoded(value) => value.as_ref(),
+ Self::External(value) => value,
+ }
+ }
+}
+
+fn append_attribute_literal<S: Storage>(
+ storage: S,
+ output: &mut S::Text,
+ text: &str,
+ fold_line_ends: bool,
+) -> Result<(), crate::xml::dom::ParseError> {
+ storage.charge(text.len())?;
+ let bytes = text.as_bytes();
+ let mut start = 0;
+ let mut index = 0;
+ while index < bytes.len() {
+ if matches!(bytes[index], b'\t' | b'\n' | b'\r') {
+ storage.append(output, &text[start..index])?;
+ storage.append(output, " ")?;
+ if fold_line_ends && bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
+ index += 1;
+ }
+ start = index + 1;
+ }
+ index += 1;
+ }
+ storage.append(output, &text[start..])
+}
+
+pub(crate) fn append_normalized_line_ends<S: Storage>(
+ storage: S,
+ output: &mut S::Text,
+ text: &str,
+) -> Result<(), crate::xml::dom::ParseError> {
+ // XML 1.0 Fifth Edition §2.11: normalize source CRLF/lone CR before
+ // character-reference expansion, not characters produced by a reference.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#sec-line-ends
+ storage.charge(text.len())?;
+ let bytes = text.as_bytes();
+ let mut start = 0;
+ let mut index = 0;
+ while index < bytes.len() {
+ if bytes[index] == b'\r' {
+ storage.append(output, &text[start..index])?;
+ storage.append(output, "\n")?;
+ if bytes.get(index + 1) == Some(&b'\n') {
+ index += 1;
+ }
+ start = index + 1;
+ }
+ index += 1;
+ }
+ storage.append(output, &text[start..])
+}
// -------------------------------------------------------------------------
// Security defaults
@@ -177,26 +258,6 @@
}
}
-/// Splits an owned `QName` into `(Option<prefix>, local_name)`, reusing the
-/// original `String` buffer for the prefix portion when possible.
-///
-/// `"foo:bar".to_string()` → `(Some("foo"), "bar")` — prefix reuses the
-/// original allocation (truncated), local is a new `String`.
-///
-/// `"bar".to_string()` → `(None, "bar")` — no allocation, returns the
-/// original `String` as the local name.
-pub(crate) fn split_owned_name(name: String) -> (Option<String>, String) {
- match name.find(':') {
- Some(pos) => {
- let local = name[pos + 1..].to_string();
- let mut prefix = name;
- prefix.truncate(pos);
- (Some(prefix), local)
- }
- None => (None, name),
- }
-}
-
/// Validates that a name is a legal `QName` per Namespaces in XML 1.0 §4.
///
/// A `QName` has at most one colon, and neither prefix nor local part may be
@@ -228,22 +289,6 @@
)
}
-/// Validates that a string contains only valid `PubidChar`s.
-///
-/// Returns `None` if valid, or a descriptive error message if not.
-pub(crate) fn validate_pubid(s: &str) -> Option<String> {
- for c in s.chars() {
- if !is_pubid_char(c) {
- return Some(format!(
- "invalid character '{}' (U+{:04X}) in public ID",
- c.escape_default(),
- c as u32
- ));
- }
- }
- None
-}
-
// -------------------------------------------------------------------------
// Position checkpointing (for backtracking)
// -------------------------------------------------------------------------
@@ -266,22 +311,19 @@
// ParserInput
// -------------------------------------------------------------------------
-/// Information about an externally-declared entity (SYSTEM/PUBLIC).
-#[derive(Debug, Clone)]
-pub(crate) struct ExternalEntityInfo {
- /// The SYSTEM identifier (URI) from the entity declaration.
- pub system_id: String,
- /// The PUBLIC identifier, if any.
- pub public_id: Option<String>,
-}
-
/// Shared low-level input state for all parsers.
///
/// Tracks the byte stream, position (line/column/offset), nesting depth,
/// entity expansion count, and accumulated diagnostics. All parsers
/// (tree-building, SAX, reader, HTML) compose this struct rather than
/// reimplementing input handling.
-pub(crate) struct ParserInput<'a> {
+type ParseError<S = Native> = crate::parser_xmloxide::error::ParseError<
+ <S as Storage>::FrozenText,
+ <S as Storage>::Values<ParseDiagnostic<<S as Storage>::FrozenText>>,
+>;
+
+pub(crate) struct ParserInput<'a, S: NameStorage = Native> {
+ storage: S,
/// The input bytes (must be valid UTF-8).
input: &'a [u8],
@@ -313,16 +355,11 @@
recover: bool,
/// Accumulated diagnostics (warnings and recoverable errors).
- pub(crate) diagnostics: Vec<ParseDiagnostic>,
+ pub(crate) diagnostics: S::Values<ParseDiagnostic<S::FrozenText>>,
/// Entity replacement values from the DTD internal subset.
/// Populated after parsing `<!DOCTYPE root [ ... ]>`.
- pub(crate) entity_map: HashMap<String, String>,
-
- /// External entity declarations keyed by entity name, storing the
- /// SYSTEM and PUBLIC identifiers. Used for the entity resolver and
- /// to enforce WFC: No External Entity References in attribute values.
- pub(crate) entity_external: HashMap<String, ExternalEntityInfo>,
+ pub(crate) entity_map: S::Names<S::Text, EntityDecl<S>>,
/// Whether the DTD internal subset contained parameter entity references.
/// Per XML 1.0 §4.1 WFC: Entity Declared, undeclared entity references
@@ -336,10 +373,6 @@
/// external DTD subset that was not read.
pub(crate) has_external_dtd: bool,
- /// Entities whose content production has already been validated.
- /// Prevents redundant re-validation on repeated references.
- validated_entities: std::collections::HashSet<String>,
-
/// Optional callback for resolving external entities.
entity_resolver: Option<EntityResolver>,
}
@@ -347,7 +380,28 @@
impl<'a> ParserInput<'a> {
/// Creates a new `ParserInput` from a UTF-8 string with default limits.
pub fn new(input: &'a str) -> Self {
- Self {
+ Self::with_storage(input, Native).expect("empty native input state has no allocation")
+ }
+
+ /// The donor callback is available only to native parsing. Scoped XML
+ /// security input has no external-loading capability to install.
+ pub fn set_entity_resolver(&mut self, resolver: Option<EntityResolver>) {
+ self.entity_resolver = resolver;
+ }
+}
+
+impl<'a, S: NameStorage> ParserInput<'a, S> {
+ pub(crate) fn external_entity_resolver(&self) -> Option<&EntityResolver> {
+ self.entity_resolver.as_ref()
+ }
+
+ pub(crate) fn inherit_entity_resolver(&mut self, parent: &Self) {
+ self.entity_resolver = parent.entity_resolver.clone();
+ }
+
+ pub fn with_storage(input: &'a str, storage: S) -> Result<Self, ParseError<S>> {
+ Ok(Self {
+ storage,
input: input.as_bytes(),
pos: 0,
line: 1,
@@ -358,19 +412,12 @@
entity_expansions: 0,
max_entity_expansions: DEFAULT_MAX_ENTITY_EXPANSIONS,
recover: false,
- diagnostics: Vec::new(),
- entity_map: HashMap::new(),
- entity_external: HashMap::new(),
+ diagnostics: storage.values(0)?,
+ entity_map: storage.names(),
has_pe_references: false,
has_external_dtd: false,
- validated_entities: std::collections::HashSet::new(),
entity_resolver: None,
- }
- }
-
- /// Sets the external entity resolver.
- pub fn set_entity_resolver(&mut self, resolver: Option<EntityResolver>) {
- self.entity_resolver = resolver;
+ })
}
/// Sets the maximum nesting depth.
@@ -398,13 +445,32 @@
self.recover
}
+ pub fn validate_pubid(&mut self, value: &str) -> Result<(), ParseError<S>> {
+ self.storage.charge(value.len())?;
+ for character in value.chars() {
+ if !is_pubid_char(character) {
+ let message = format_args!(
+ "invalid character '{}' (U+{:04X}) in public ID",
+ character.escape_default(),
+ character as u32
+ );
+ if self.recover {
+ self.push_diagnostic(ErrorSeverity::Warning, message)?;
+ return Ok(());
+ }
+ return Err(self.fatal(message));
+ }
+ }
+ Ok(())
+ }
+
// -- Depth tracking --
/// Increments the nesting depth. Returns an error if the limit is exceeded.
- pub fn increment_depth(&mut self) -> Result<(), ParseError> {
+ pub fn increment_depth(&mut self) -> Result<(), ParseError<S>> {
self.depth += 1;
if self.depth > self.max_depth {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"maximum nesting depth exceeded ({})",
self.max_depth
)));
@@ -458,7 +524,7 @@
/// Returns a slice of the raw input bytes from `start` to `end`.
#[allow(dead_code)]
#[inline]
- pub fn slice(&self, start: usize, end: usize) -> &[u8] {
+ pub fn slice(&self, start: usize, end: usize) -> &'a [u8] {
&self.input[start..end]
}
@@ -537,9 +603,10 @@
/// Advances the position by `count` bytes, updating line/column.
#[inline]
- pub fn advance(&mut self, count: usize) {
+ pub fn advance(&mut self, count: usize) -> Result<(), ParseError<S>> {
// Fast path for count == 1 (the most common case from expect_byte, etc.)
if count == 1 {
+ self.storage.charge(1)?;
if self.pos < self.input.len() {
if self.input[self.pos] == b'\n' {
self.line += 1;
@@ -549,15 +616,16 @@
}
self.pos += 1;
}
- return;
+ return Ok(());
}
- self.advance_counting_lines(count.min(self.input.len() - self.pos));
+ self.advance_counting_lines(count.min(self.input.len() - self.pos))
}
/// Advances by one UTF-8 character, updating line/column.
#[inline]
- pub fn advance_char(&mut self, ch: char) {
+ pub fn advance_char(&mut self, ch: char) -> Result<(), ParseError<S>> {
let len = ch.len_utf8();
+ self.storage.charge(len)?;
if ch == '\n' {
self.line += 1;
self.column = 1;
@@ -565,23 +633,25 @@
self.column += 1;
}
self.pos += len;
+ Ok(())
}
/// Consumes and returns the next byte, or returns an error at EOF.
#[inline]
- pub fn next_byte(&mut self) -> Result<u8, ParseError> {
+ pub fn next_byte(&mut self) -> Result<u8, ParseError<S>> {
if self.at_end() {
return Err(self.fatal("unexpected end of input"));
}
let b = self.input[self.pos];
- self.advance(1);
+ self.advance(1)?;
Ok(b)
}
/// Consumes and returns the next character with `\r\n` normalization
/// (XML 1.0 §2.11) and character validation (XML 1.0 §2.2).
#[inline]
- pub fn next_char(&mut self) -> Result<char, ParseError> {
+ pub fn next_char(&mut self) -> Result<char, ParseError<S>> {
+ self.storage.charge(4)?;
if self.pos >= self.input.len() {
return Err(self.fatal("unexpected end of input"));
}
@@ -608,11 +678,11 @@
if self.recover {
self.push_diagnostic(
ErrorSeverity::Error,
- format!("invalid XML character: U+{:04X}", ch as u32),
- );
+ format_args!("invalid XML character: U+{:04X}", ch as u32),
+ )?;
} else {
return Err(
- self.fatal(format!("invalid XML character: U+{:04X}", ch as u32))
+ self.fatal(format_args!("invalid XML character: U+{:04X}", ch as u32))
);
}
}
@@ -623,16 +693,16 @@
let ch = self
.peek_char()
.ok_or_else(|| self.fatal("unexpected end of input"))?;
- self.advance_char(ch);
+ self.advance_char(ch)?;
// Validate against XML 1.0 §2.2 Char production
if !is_xml_char(ch) {
if self.recover {
self.push_diagnostic(
ErrorSeverity::Error,
- format!("invalid XML character: U+{:04X}", ch as u32),
- );
+ format_args!("invalid XML character: U+{:04X}", ch as u32),
+ )?;
} else {
- return Err(self.fatal(format!("invalid XML character: U+{:04X}", ch as u32)));
+ return Err(self.fatal(format_args!("invalid XML character: U+{:04X}", ch as u32)));
}
}
Ok(ch)
@@ -640,31 +710,46 @@
// -- Bulk scanning --
+ fn scan_bytes(
+ &self,
+ candidates: usize,
+ width: usize,
+ mut stop: impl FnMut(&[u8], usize) -> bool,
+ ) -> Result<usize, ParseError<S>> {
+ let bytes = &self.input[self.pos..];
+ let mut index = 0;
+ while index < candidates {
+ // Admit bounded scan blocks before inspecting them. The block size
+ // is an implementation granularity, never a caller policy limit.
+ let end = index + (candidates - index).min(64);
+ self.storage.charge((end - index) * width)?;
+ while index < end {
+ if stop(bytes, index) {
+ return Ok(index);
+ }
+ index += 1;
+ }
+ }
+ Ok(candidates)
+ }
+
/// Scans forward from the current position to find the next character data
/// boundary (`<`, `&`, or `]]>`). Returns the number of safe bytes that
/// can be consumed as plain text content.
#[inline]
- pub fn scan_char_data(&self) -> usize {
- let bytes = &self.input[self.pos..];
- let mut i = 0;
- while i < bytes.len() {
+ pub fn scan_char_data(&self) -> Result<usize, ParseError<S>> {
+ self.scan_bytes(self.input.len() - self.pos, 3, |bytes, i| {
let b = bytes[i];
match b {
- b'<' | b'&' => return i,
- b']' if i + 2 < bytes.len() && bytes[i + 1] == b']' && bytes[i + 2] == b'>' => {
- return i;
- }
+ b'<' | b'&' => true,
+ b']' if i + 2 < bytes.len() && bytes[i + 1] == b']' && bytes[i + 2] == b'>' => true,
_ => {
// Stop at invalid XML control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F)
// so they fall through to next_char() which validates and reports errors.
- if b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' {
- return i;
- }
- i += 1;
+ b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r'
}
}
- }
- bytes.len()
+ })
}
/// Scans forward to find the next attribute value delimiter: the given
@@ -672,22 +757,16 @@
/// Returns the number of safe bytes before the delimiter.
/// Avoids the 256-byte lookup table of `scan_until_any`.
#[inline]
- pub fn scan_attr_value(&self, quote: u8) -> usize {
- let bytes = &self.input[self.pos..];
- let mut i = 0;
- while i < bytes.len() {
+ pub fn scan_attr_value(&self, quote: u8) -> Result<usize, ParseError<S>> {
+ self.scan_bytes(self.input.len() - self.pos, 1, |bytes, i| {
let b = bytes[i];
if b == quote || b == b'&' || b == b'<' {
- return i;
+ return true;
}
// Stop at invalid XML control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F)
// so they fall through to next_char() which validates and reports errors.
- if b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' {
- return i;
- }
- i += 1;
- }
- bytes.len()
+ b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r'
+ })
}
/// Scans forward to find the next occurrence of any of the given marker
@@ -696,63 +775,72 @@
/// Uses a 256-byte lookup table for O(1) per-byte matching instead of
/// linear `contains()` on the marker slice.
#[allow(dead_code)]
- pub fn scan_until_any(&self, markers: &[u8]) -> usize {
+ pub fn scan_until_any(&self, markers: &[u8]) -> Result<usize, ParseError<S>> {
+ self.storage.charge(markers.len())?;
let mut marker_set = [false; 256];
for &m in markers {
marker_set[m as usize] = true;
}
- let bytes = &self.input[self.pos..];
- for (i, &b) in bytes.iter().enumerate() {
- if marker_set[b as usize] {
- return i;
- }
- }
- bytes.len()
+ self.scan_bytes(self.input.len() - self.pos, 1, |bytes, i| {
+ marker_set[bytes[i] as usize]
+ })
}
/// Scans forward to find a 2-byte terminator sequence (e.g., `?>`, `--`).
/// Returns the number of bytes before the first byte of the terminator,
/// or `None` if not found.
- pub fn scan_for_2byte_terminator(&self, t0: u8, t1: u8) -> Option<usize> {
+ pub fn scan_for_2byte_terminator(
+ &self,
+ t0: u8,
+ t1: u8,
+ ) -> Result<Option<usize>, ParseError<S>> {
let bytes = &self.input[self.pos..];
if bytes.len() < 2 {
- return None;
+ return Ok(None);
}
- let mut i = 0;
let end = bytes.len() - 1;
- while i < end {
- if bytes[i] == t0 && bytes[i + 1] == t1 {
- return Some(i);
- }
- i += 1;
+ let found = self.scan_bytes(end, 2, |bytes, i| bytes[i] == t0 && bytes[i + 1] == t1)?;
+ if found < end {
+ Ok(Some(found))
+ } else {
+ Ok(None)
}
- None
}
/// Scans forward to find a 3-byte terminator sequence (e.g., `-->`, `]]>`).
/// Returns the number of bytes before the first byte of the terminator,
/// or `None` if not found.
- pub fn scan_for_3byte_terminator(&self, t0: u8, t1: u8, t2: u8) -> Option<usize> {
+ pub fn scan_for_3byte_terminator(
+ &self,
+ t0: u8,
+ t1: u8,
+ t2: u8,
+ ) -> Result<Option<usize>, ParseError<S>> {
let bytes = &self.input[self.pos..];
if bytes.len() < 3 {
- return None;
+ return Ok(None);
}
- let mut i = 0;
let end = bytes.len() - 2;
- while i < end {
- if bytes[i] == t0 && bytes[i + 1] == t1 && bytes[i + 2] == t2 {
- return Some(i);
- }
- i += 1;
+ let found = self.scan_bytes(end, 3, |bytes, i| {
+ bytes[i] == t0 && bytes[i + 1] == t1 && bytes[i + 2] == t2
+ })?;
+ if found < end {
+ Ok(Some(found))
+ } else {
+ Ok(None)
}
- None
}
/// Advances the position by `count` bytes, tracking line/column numbers
/// in a single pass. More efficient than calling `advance(1)` in a loop
/// for bulk text consumption.
#[inline]
- pub fn advance_counting_lines(&mut self, count: usize) {
+ pub fn advance_counting_lines(&mut self, count: usize) -> Result<(), ParseError<S>> {
+ // A source slice has at most isize::MAX bytes, so both passes over
+ // this valid window fit usize without depending on XMLDSig errors.
+ debug_assert!(count <= self.input.len() - self.pos);
+ debug_assert!(count <= usize::MAX / 2);
+ self.storage.charge(count * 2)?;
let end = self.pos + count;
let slice = &self.input[self.pos..end];
// Fast path: if no newlines in the slice, just bump column
@@ -772,16 +860,17 @@
}
}
self.pos = end;
+ Ok(())
}
// -- Expect operations --
/// Consumes the next byte and asserts it matches `expected`.
#[inline]
- pub fn expect_byte(&mut self, expected: u8) -> Result<(), ParseError> {
+ pub fn expect_byte(&mut self, expected: u8) -> Result<(), ParseError<S>> {
let b = self.next_byte()?;
if b != expected {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"expected '{}', found '{}'",
expected as char, b as char
)));
@@ -791,12 +880,13 @@
/// Consumes bytes and asserts they match the `expected` sequence.
#[inline]
- pub fn expect_str(&mut self, expected: &[u8]) -> Result<(), ParseError> {
+ pub fn expect_str(&mut self, expected: &[u8]) -> Result<(), ParseError<S>> {
if self.pos + expected.len() > self.input.len() {
return Err(self.fatal("unexpected end of input"));
}
+ self.storage.charge(expected.len())?;
if &self.input[self.pos..self.pos + expected.len()] == expected {
- self.advance_counting_lines(expected.len());
+ self.advance_counting_lines(expected.len())?;
} else {
// Fall back to per-byte for precise error reporting
for &b in expected {
@@ -828,51 +918,30 @@
/// Skips whitespace characters. Returns `true` if any were consumed.
#[inline]
- pub fn skip_whitespace(&mut self) -> bool {
+ pub fn skip_whitespace(&mut self) -> Result<bool, ParseError<S>> {
let start = self.pos;
- while self.pos < self.input.len() {
- match self.input[self.pos] {
- b'\n' => {
- self.line += 1;
- self.column = 1;
- self.pos += 1;
- }
- b' ' | b'\t' | b'\r' => {
- self.column += 1;
- self.pos += 1;
- }
- _ => break,
- }
- }
- self.pos > start
+ let count = self.scan_bytes(self.input.len() - self.pos, 1, |bytes, i| {
+ !matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r')
+ })?;
+ self.advance_counting_lines(count)?;
+ Ok(self.pos > start)
}
/// Consumes and returns any whitespace characters at the current position.
///
/// Returns the consumed whitespace as a `&str` (empty if no whitespace).
- pub fn consume_whitespace(&mut self) -> &str {
+ pub fn consume_whitespace(&mut self) -> Result<&'a str, ParseError<S>> {
let start = self.pos;
- while self.pos < self.input.len() {
- match self.input[self.pos] {
- b'\n' => {
- self.line += 1;
- self.column = 1;
- self.pos += 1;
- }
- b' ' | b'\t' | b'\r' => {
- self.column += 1;
- self.pos += 1;
- }
- _ => break,
- }
- }
+ self.skip_whitespace()?;
+ self.storage.charge(self.pos - start)?;
// Whitespace bytes are valid ASCII/UTF-8, so this conversion is safe.
- std::str::from_utf8(&self.input[start..self.pos]).unwrap_or_default()
+ Ok(std::str::from_utf8(&self.input[start..self.pos])
+ .expect("whitespace scanner consumes ASCII"))
}
/// Skips whitespace, returning an error if none is found.
- pub fn skip_whitespace_required(&mut self) -> Result<(), ParseError> {
- if !self.skip_whitespace() {
+ pub fn skip_whitespace_required(&mut self) -> Result<(), ParseError<S>> {
+ if !self.skip_whitespace()? {
return Err(self.fatal("whitespace required"));
}
Ok(())
@@ -880,23 +949,18 @@
// -- Take while --
- /// Consumes bytes while `pred` returns `true` and returns the string.
- pub fn take_while(&mut self, pred: impl Fn(u8) -> bool) -> String {
+ /// Consumes ASCII bytes while `pred` returns `true`, borrowing the source.
+ pub fn take_while(&mut self, pred: impl Fn(u8) -> bool) -> Result<&'a str, ParseError<S>> {
let start = self.pos;
- while self.pos < self.input.len() && pred(self.input[self.pos]) {
- if self.input[self.pos] == b'\n' {
- self.line += 1;
- self.column = 1;
- } else {
- self.column += 1;
- }
- self.pos += 1;
- }
+ let count = self.scan_bytes(self.input.len() - self.pos, 1, |bytes, i| {
+ !bytes[i].is_ascii() || !pred(bytes[i])
+ })?;
+ self.advance_counting_lines(count)?;
+ self.storage.charge(count)?;
// The predicates used (ascii_digit, ascii_hexdigit) only match ASCII,
// so the consumed range is always valid UTF-8.
- std::str::from_utf8(&self.input[start..self.pos])
- .unwrap_or("")
- .to_string()
+ Ok(std::str::from_utf8(&self.input[start..self.pos])
+ .expect("numeric scanners consume ASCII on UTF-8 boundaries"))
}
// -- Name parsing (XML 1.0 §2.3) --
@@ -910,7 +974,16 @@
/// Uses an ASCII fast path that scans name characters as bytes,
/// avoiding per-character UTF-8 decoding for the common case.
#[inline]
- pub fn parse_name(&mut self) -> Result<String, ParseError> {
+ pub fn parse_name(&mut self) -> Result<&'a str, ParseError<S>> {
+ self.parse_name_token(NameTokenKind::Name)
+ }
+
+ pub(crate) fn parse_nmtoken(&mut self) -> Result<&'a str, ParseError<S>> {
+ self.parse_name_token(NameTokenKind::NmToken)
+ }
+
+ fn parse_name_token(&mut self, kind: NameTokenKind) -> Result<&'a str, ParseError<S>> {
+ self.storage.charge(4)?;
let start = self.pos;
if self.pos >= self.input.len() {
return Err(self.fatal("expected name, found end of input"));
@@ -919,26 +992,31 @@
let first = self.input[self.pos];
// ASCII fast path: most XML names are pure ASCII
- if is_ascii_name_start(first) {
+ let valid_start = match kind {
+ NameTokenKind::Name => is_ascii_name_start(first),
+ NameTokenKind::NmToken => is_ascii_name_char(first),
+ };
+ if valid_start {
self.pos += 1;
self.column += 1;
- while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) {
- self.pos += 1;
- self.column += 1;
- }
+ let count = self.scan_bytes(self.input.len() - self.pos, 1, |bytes, i| {
+ !is_ascii_name_char(bytes[i])
+ })?;
+ self.advance_counting_lines(count)?;
// Check if we stopped at a non-ASCII byte (need slow path)
if self.pos >= self.input.len() || self.input[self.pos] < 0x80 {
let len = self.pos - start;
if len > self.max_name_length {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"name length ({len}) exceeds maximum ({})",
self.max_name_length
)));
}
// Input is guaranteed valid UTF-8 and we only consumed ASCII bytes
+ self.storage.charge(len)?;
let name = std::str::from_utf8(&self.input[start..self.pos])
.map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- return Ok(name.to_string());
+ return Ok(name);
}
// Fall through: hit a non-ASCII continuation byte, continue
// with the char-by-char path below.
@@ -948,16 +1026,22 @@
let ch = self
.peek_char()
.ok_or_else(|| self.fatal("expected name"))?;
- if !is_name_start_char(ch) {
- return Err(self.fatal(format!("invalid name start character: '{ch}'")));
+ let valid_start = match kind {
+ NameTokenKind::Name => is_name_start_char(ch),
+ NameTokenKind::NmToken => is_name_char(ch),
+ };
+ if !valid_start {
+ return Err(self.fatal(format_args!("invalid name start character: '{ch}'")));
}
- self.advance_char(ch);
+ self.advance_char(ch)?;
}
// Slow path: handles non-ASCII name characters
- while let Some(ch) = self.peek_char() {
+ while self.pos < self.input.len() {
+ self.storage.charge(4)?;
+ let Some(ch) = self.peek_char() else { break };
if is_name_char(ch) {
- self.advance_char(ch);
+ self.advance_char(ch)?;
} else {
break;
}
@@ -965,90 +1049,32 @@
let len = self.pos - start;
if len > self.max_name_length {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"name length ({len}) exceeds maximum ({})",
self.max_name_length
)));
}
+ self.storage.charge(len)?;
let name = std::str::from_utf8(&self.input[start..self.pos])
.map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- Ok(name.to_string())
+ Ok(name)
}
/// Parses a name from the input and checks whether it matches `expected`.
///
/// Advances past the parsed name in all cases. Returns `Ok(None)` if the
/// name matches, or `Ok(Some(parsed_name))` if it doesn't (the caller
- /// gets the actual name for error messages). This avoids allocating a
- /// `String` in the happy path (matching names).
+ /// gets the actual name for error messages). Both paths borrow the ordinary
+ /// scanner's input window and share its grammar and limits.
#[allow(dead_code)]
- pub fn parse_name_eq(&mut self, expected: &str) -> Result<Option<String>, ParseError> {
- let start = self.pos;
- if self.pos >= self.input.len() {
- return Err(self.fatal("expected name, found end of input"));
- }
-
- let first = self.input[self.pos];
-
- // ASCII fast path
- if is_ascii_name_start(first) {
- self.pos += 1;
- self.column += 1;
- while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) {
- self.pos += 1;
- self.column += 1;
- }
- if self.pos >= self.input.len() || self.input[self.pos] < 0x80 {
- let len = self.pos - start;
- if len > self.max_name_length {
- return Err(self.fatal(format!(
- "name length ({len}) exceeds maximum ({})",
- self.max_name_length
- )));
- }
- // Compare directly against input bytes — no allocation needed
- if len == expected.len() && &self.input[start..self.pos] == expected.as_bytes() {
- return Ok(None); // match — no allocation
- }
- let name = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- return Ok(Some(name.to_string()));
- }
- // Fall through to slow path for non-ASCII
- } else {
- let ch = self
- .peek_char()
- .ok_or_else(|| self.fatal("expected name"))?;
- if !is_name_start_char(ch) {
- return Err(self.fatal(format!("invalid name start character: '{ch}'")));
- }
- self.advance_char(ch);
- }
-
- // Slow path for non-ASCII names
- while let Some(ch) = self.peek_char() {
- if is_name_char(ch) {
- self.advance_char(ch);
- } else {
- break;
- }
- }
-
- let len = self.pos - start;
- if len > self.max_name_length {
- return Err(self.fatal(format!(
- "name length ({len}) exceeds maximum ({})",
- self.max_name_length
- )));
- }
-
- if len == expected.len() && &self.input[start..self.pos] == expected.as_bytes() {
+ pub fn parse_name_eq(&mut self, expected: &str) -> Result<Option<&'a str>, ParseError<S>> {
+ let name = self.parse_name()?;
+ self.storage.charge(name.len())?;
+ if name == expected {
return Ok(None);
}
- let name = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- Ok(Some(name.to_string()))
+ Ok(Some(name))
}
/// Parses a name and checks whether it matches the given prefix + local
@@ -1060,93 +1086,25 @@
&mut self,
prefix: Option<&str>,
local: &str,
- ) -> Result<Option<String>, ParseError> {
- let start = self.pos;
- if self.pos >= self.input.len() {
- return Err(self.fatal("expected name, found end of input"));
- }
-
- let first = self.input[self.pos];
-
- // ASCII fast path
- if is_ascii_name_start(first) {
- self.pos += 1;
- self.column += 1;
- while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) {
- self.pos += 1;
- self.column += 1;
- }
- if self.pos >= self.input.len() || self.input[self.pos] < 0x80 {
- let len = self.pos - start;
- if len > self.max_name_length {
- return Err(self.fatal(format!(
- "name length ({len}) exceeds maximum ({})",
- self.max_name_length
- )));
- }
- let parsed = &self.input[start..self.pos];
- // Compare against prefix:local parts
- let matches = match prefix {
- Some(pfx) => {
- let expected_len = pfx.len() + 1 + local.len();
- len == expected_len
- && parsed[..pfx.len()] == *pfx.as_bytes()
- && parsed[pfx.len()] == b':'
- && parsed[pfx.len() + 1..] == *local.as_bytes()
- }
- None => len == local.len() && parsed == local.as_bytes(),
- };
- if matches {
- return Ok(None);
- }
- let name =
- std::str::from_utf8(parsed).map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- return Ok(Some(name.to_string()));
- }
- // Fall through to slow path for non-ASCII
- } else {
- let ch = self
- .peek_char()
- .ok_or_else(|| self.fatal("expected name"))?;
- if !is_name_start_char(ch) {
- return Err(self.fatal(format!("invalid name start character: '{ch}'")));
- }
- self.advance_char(ch);
- }
-
- // Slow path for non-ASCII names
- while let Some(ch) = self.peek_char() {
- if is_name_char(ch) {
- self.advance_char(ch);
- } else {
- break;
- }
- }
-
- let len = self.pos - start;
- if len > self.max_name_length {
- return Err(self.fatal(format!(
- "name length ({len}) exceeds maximum ({})",
- self.max_name_length
- )));
- }
-
- let parsed = &self.input[start..self.pos];
+ ) -> Result<Option<&'a str>, ParseError<S>> {
+ let name = self.parse_name()?;
+ self.storage.charge(name.len())?;
let matches = match prefix {
- Some(pfx) => {
- let expected_len = pfx.len() + 1 + local.len();
- len == expected_len
- && parsed[..pfx.len()] == *pfx.as_bytes()
- && parsed[pfx.len()] == b':'
- && parsed[pfx.len() + 1..] == *local.as_bytes()
+ Some(prefix) => {
+ if let Some(suffix) = name.strip_prefix(prefix)
+ && let Some(local_name) = suffix.strip_prefix(':')
+ {
+ local_name == local
+ } else {
+ false
+ }
}
- None => len == local.len() && parsed == local.as_bytes(),
+ None => name == local,
};
if matches {
return Ok(None);
}
- let name = std::str::from_utf8(parsed).map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- Ok(Some(name.to_string()))
+ Ok(Some(name))
}
// -- Reference parsing (XML 1.0 §4.1) --
@@ -1154,17 +1112,19 @@
/// Counts one entity expansion against the security limit.
///
/// Returns an error if the configured maximum is exceeded.
- pub fn count_entity_expansion(&mut self) -> Result<(), ParseError> {
+ pub fn count_entity_expansion(&mut self) -> Result<(), ParseError<S>> {
self.entity_expansions += 1;
if self.entity_expansions > self.max_entity_expansions {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"entity expansion limit exceeded ({})",
self.max_entity_expansions
)));
}
Ok(())
}
+}
+impl<'a, S: NameStorage> ParserInput<'a, S> {
/// Parses an entity or character reference (`&...;`).
///
/// Handles the five built-in XML entities (`amp`, `lt`, `gt`, `apos`,
@@ -1175,8 +1135,8 @@
/// Increments the entity expansion counter and returns an error if the
/// limit is exceeded.
#[cfg(test)]
- pub fn parse_reference(&mut self) -> Result<String, ParseError> {
- let mut buf = String::new();
+ pub fn parse_reference(&mut self) -> Result<S::Text, ParseError<S>> {
+ let mut buf = self.storage.text("")?;
self.parse_reference_into(&mut buf)?;
Ok(buf)
}
@@ -1190,34 +1150,46 @@
///
/// Returns the resolved text as a `&str` slice of `buf` (the portion
/// that was appended), which callers can use for validation.
- pub fn parse_reference_into<'b>(&mut self, buf: &'b mut String) -> Result<&'b str, ParseError> {
- self.count_entity_expansion()?;
+ pub fn parse_reference_into<'b>(
+ &mut self,
+ buf: &'b mut S::Text,
+ ) -> Result<&'b str, ParseError<S>> {
+ self.parse_reference_in(buf, ReferenceContext::Content)
+ }
+ fn parse_reference_in<'b>(
+ &mut self,
+ buf: &'b mut S::Text,
+ context: ReferenceContext,
+ ) -> Result<&'b str, ParseError<S>> {
+ let reference_start = self.pos;
self.expect_byte(b'&')?;
// Fast path: recognize builtin entities at byte level
let remaining = &self.input[self.pos..];
if let Some(result) = match_builtin_entity(remaining) {
+ self.count_entity_expansion()?;
let advance_len = result.1;
- self.advance_counting_lines(advance_len);
+ self.advance_counting_lines(advance_len)?;
let start = buf.len();
- buf.push_str(result.0);
+ self.storage.append(buf, result.0)?;
return Ok(&buf[start..]);
}
if self.peek() == Some(b'#') {
+ self.count_entity_expansion()?;
// Character reference
- self.advance(1);
+ self.advance(1)?;
let value = if self.peek() == Some(b'x') {
- self.advance(1);
- let hex = self.take_while(|b| b.is_ascii_hexdigit());
+ self.advance(1)?;
+ let hex = self.take_while(|b| b.is_ascii_hexdigit())?;
if hex.is_empty() {
return Err(self.fatal("empty hex character reference"));
}
- u32::from_str_radix(&hex, 16)
+ u32::from_str_radix(hex, 16)
.map_err(|_| self.fatal("invalid hex character reference"))?
} else {
- let dec = self.take_while(|b| b.is_ascii_digit());
+ let dec = self.take_while(|b| b.is_ascii_digit())?;
if dec.is_empty() {
return Err(self.fatal("empty decimal character reference"));
}
@@ -1226,265 +1198,298 @@
};
self.expect_byte(b';')?;
- let ch = char::from_u32(value)
- .ok_or_else(|| self.fatal(format!("invalid character reference: U+{value:04X}")))?;
+ let ch = char::from_u32(value).ok_or_else(|| {
+ self.fatal(format_args!("invalid character reference: U+{value:04X}"))
+ })?;
if !is_xml_char(ch) {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"character reference &#x{value:X}; does not refer to a valid XML character"
)));
}
let start = buf.len();
- buf.push(ch);
+ let mut encoded = [0; 4];
+ self.storage.append(buf, ch.encode_utf8(&mut encoded))?;
Ok(&buf[start..])
} else {
- // General entity reference — delegate to parse_reference logic
- // (rare path, allocation is acceptable)
- let name = self.parse_name()?;
+ self.parse_name()?;
self.expect_byte(b';')?;
-
- let expanded = match name.as_str() {
- "amp" | "lt" | "gt" | "apos" | "quot" => {
- unreachable!("builtin entity should be caught by fast path")
- }
- _ => {
- if let Some(info) = self.entity_external.get(&name).cloned() {
- if let Some(ref resolver) = self.entity_resolver.clone() {
- let request = ExternalEntityRequest {
- name: &name,
- system_id: &info.system_id,
- public_id: info.public_id.as_deref(),
- };
- if let Some(resolved) = resolver(request) {
- self.expand_entity_text(&resolved)?
- } else {
- return Err(self.fatal(format!(
- "reference to external entity '{name}' is not supported"
- )));
- }
- } else {
- return Err(self.fatal(format!(
- "reference to external entity '{name}' is not supported"
- )));
- }
- } else if let Some(value) = self.entity_map.get(&name).cloned() {
- if !self.validated_entities.contains(&name) {
- self.validated_entities.insert(name.clone());
- self.validate_entity_content(&name, &value)?;
- }
- self.expand_entity_text(&value)?
- } else if self.recover || self.has_pe_references || self.has_external_dtd {
- self.push_diagnostic(
- ErrorSeverity::Warning,
- format!("unknown entity reference: &{name};"),
- );
- String::new()
- } else {
- return Err(self.fatal(format!("unknown entity reference: &{name};")));
- }
- }
- };
let start = buf.len();
- buf.push_str(&expanded);
+ self.storage.charge(self.pos - reference_start)?;
+ let reference = std::str::from_utf8(self.slice(reference_start, self.pos))
+ .expect("a parsed reference preserves UTF-8 boundaries");
+ self.expand_entity_into(reference, buf, context)?;
Ok(&buf[start..])
}
}
+}
+impl<S: NameStorage> ParserInput<'_, S> {
/// Expands entity and character references in entity replacement text.
///
/// Per XML 1.0 §4.4, when an entity's replacement text is included,
/// character references and entity references within it are resolved.
- /// This method performs that resolution recursively, using the entity
- /// map populated from the DTD.
+ /// Continuations borrow retained DTD values instead of recursively cloning
+ /// each value and constructing an intermediate output for every ancestor.
#[allow(clippy::too_many_lines)]
- fn expand_entity_text(&mut self, text: &str) -> Result<String, ParseError> {
- // Fast path — no references to expand
- if !text.contains('&') {
- return Ok(text.to_string());
- }
-
- let bytes = text.as_bytes();
- let mut result = String::with_capacity(text.len());
- let mut i = 0;
- let mut in_cdata = false;
-
- while i < bytes.len() {
- // Track CDATA sections — entity references inside CDATA are
- // literal text and should not be expanded.
- if !in_cdata && i + 8 < bytes.len() && &bytes[i..i + 9] == b"<![CDATA[" {
- in_cdata = true;
- result.push_str("<![CDATA[");
- i += 9;
- continue;
- }
- if in_cdata {
- if i + 2 < bytes.len() && &bytes[i..i + 3] == b"]]>" {
- in_cdata = false;
- result.push_str("]]>");
- i += 3;
- } else {
- result.push(bytes[i] as char);
- i += 1;
+ fn expand_entity_text(&mut self, text: &str) -> Result<S::Text, ParseError<S>> {
+ let mut result = self.storage.text("")?;
+ self.expand_entity_into(text, &mut result, ReferenceContext::Content)?;
+ Ok(result)
+ }
+
+ fn expand_entity_into(
+ &mut self,
+ text: &str,
+ output: &mut S::Text,
+ context: ReferenceContext,
+ ) -> Result<(), ParseError<S>> {
+ self.storage.charge(text.len())?;
+ if context == ReferenceContext::Content && !text.contains('&') {
+ self.storage.append(output, text)?;
+ return Ok(());
+ }
+ struct Frame<'a, S: Storage> {
+ text: Replacement<'a, S>,
+ position: usize,
+ fold_line_ends: bool,
+ }
+ let mut frames: S::Values<Frame<'_, S>> = self.storage.values(0)?;
+ Values::push(
+ &mut frames,
+ Frame {
+ text: Replacement::Borrowed(text),
+ position: 0,
+ fold_line_ends: true,
+ },
+ )?;
+ let result = output;
+ while let Some(mut frame) = frames.pop() {
+ let text = frame.text.as_ref();
+ let bytes = text.as_bytes();
+ let mut i = frame.position;
+ if i == 0 {
+ self.storage.charge(bytes.len())?;
+ self.storage.charge(bytes.len())?;
+ self.storage.charge(bytes.len())?;
+ }
+ while i < bytes.len() {
+ if context == ReferenceContext::Content && bytes[i..].starts_with(b"<![CDATA[") {
+ // XML 1.0 Fifth Edition §2.7: only CDEnd is recognized
+ // inside CDATA. Preserve the original UTF-8 window.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#sec-cdata-sect
+ let start = i;
+ i += 9;
+ while i < bytes.len() && !bytes[i..].starts_with(b"]]>") {
+ i += 1;
+ }
+ if i < bytes.len() {
+ i += 3;
+ }
+ self.storage.append(result, &text[start..i])?;
+ continue;
}
- continue;
- }
- if bytes[i] == b'&' {
- i += 1;
- if i < bytes.len() && bytes[i] == b'#' {
- // Character reference
+ if bytes[i] == b'&' {
i += 1;
- let char_val = if i < bytes.len() && bytes[i] == b'x' {
+ if i < bytes.len() && bytes[i] == b'#' {
+ // Character reference
i += 1;
- let start = i;
- while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
+ let char_val = if i < bytes.len() && bytes[i] == b'x' {
i += 1;
+ let start = i;
+ while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
+ i += 1;
+ }
+ let hex = std::str::from_utf8(&bytes[start..i])
+ .map_err(|_| self.fatal("invalid UTF-8 in entity value"))?;
+ u32::from_str_radix(hex, 16)
+ .map_err(|_| self.fatal("invalid hex character reference"))?
+ } else {
+ let start = i;
+ while i < bytes.len() && bytes[i].is_ascii_digit() {
+ i += 1;
+ }
+ let dec = std::str::from_utf8(&bytes[start..i])
+ .map_err(|_| self.fatal("invalid UTF-8 in entity value"))?;
+ dec.parse::<u32>()
+ .map_err(|_| self.fatal("invalid decimal character reference"))?
+ };
+ if i >= bytes.len() || bytes[i] != b';' {
+ return Err(
+ self.fatal("incomplete character reference in entity value")
+ );
}
- let hex = std::str::from_utf8(&bytes[start..i])
- .map_err(|_| self.fatal("invalid UTF-8 in entity value"))?;
- u32::from_str_radix(hex, 16)
- .map_err(|_| self.fatal("invalid hex character reference"))?
+ i += 1;
+ let ch = char::from_u32(char_val).ok_or_else(|| {
+ self.fatal(format_args!(
+ "invalid character reference: U+{char_val:04X}"
+ ))
+ })?;
+ if !is_xml_char(ch) {
+ return Err(self.fatal("invalid XML character in entity reference"));
+ }
+ let mut encoded = [0; 4];
+ self.storage.append(result, ch.encode_utf8(&mut encoded))?;
} else {
+ // Entity reference
let start = i;
- while i < bytes.len() && bytes[i].is_ascii_digit() {
+ while i < bytes.len() && bytes[i] != b';' {
i += 1;
}
- let dec = std::str::from_utf8(&bytes[start..i])
- .map_err(|_| self.fatal("invalid UTF-8 in entity value"))?;
- dec.parse::<u32>()
- .map_err(|_| self.fatal("invalid decimal character reference"))?
- };
- if i >= bytes.len() || bytes[i] != b';' {
- return Err(self.fatal("incomplete character reference in entity value"));
- }
- i += 1;
- let ch = char::from_u32(char_val).ok_or_else(|| {
- self.fatal(format!("invalid character reference: U+{char_val:04X}"))
- })?;
- result.push(ch);
- } else {
- // Entity reference
- let start = i;
- while i < bytes.len() && bytes[i] != b';' {
- i += 1;
- }
- if i >= bytes.len() {
- return Err(self.fatal("incomplete entity reference in entity value"));
- }
- let name = std::str::from_utf8(&bytes[start..i])
- .map_err(|_| self.fatal("invalid UTF-8 in entity name"))?;
- i += 1; // skip ';'
-
- self.entity_expansions += 1;
- if self.entity_expansions > self.max_entity_expansions {
- return Err(self.fatal(format!(
- "entity expansion limit exceeded ({})",
- self.max_entity_expansions
- )));
- }
-
- // Fast path: builtin entities push directly, no allocation
- match name {
- "amp" => {
- result.push('&');
- continue;
- }
- "lt" => {
- result.push('<');
- continue;
- }
- "gt" => {
- result.push('>');
- continue;
+ if i >= bytes.len() {
+ return Err(self.fatal("incomplete entity reference in entity value"));
}
- "apos" => {
- result.push('\'');
- continue;
+ let name = std::str::from_utf8(&bytes[start..i])
+ .map_err(|_| self.fatal("invalid UTF-8 in entity name"))?;
+ i += 1; // skip ';'
+
+ self.entity_expansions += 1;
+ if self.entity_expansions > self.max_entity_expansions {
+ return Err(self.fatal(format_args!(
+ "entity expansion limit exceeded ({})",
+ self.max_entity_expansions
+ )));
}
- "quot" => {
- result.push('"');
- continue;
+
+ // Fast path: builtin entities push directly, no allocation
+ match name {
+ "amp" => {
+ self.storage.append(result, "&")?;
+ continue;
+ }
+ "lt" => {
+ self.storage.append(result, "<")?;
+ continue;
+ }
+ "gt" => {
+ self.storage.append(result, ">")?;
+ continue;
+ }
+ "apos" => {
+ self.storage.append(result, "'")?;
+ continue;
+ }
+ "quot" => {
+ self.storage.append(result, "\"")?;
+ continue;
+ }
+ _ => {}
}
- _ => {}
- }
- let expanded = if let Some(info) = self.entity_external.get(name).cloned() {
- if let Some(ref resolver) = self.entity_resolver.clone() {
- let request = ExternalEntityRequest {
- name,
- system_id: &info.system_id,
- public_id: info.public_id.as_deref(),
- };
- if let Some(resolved) = resolver(request) {
- self.expand_entity_text(&resolved)?
+ let declaration = self.entity_map.get(name, self.storage)?;
+ let replacement = if let Some(EntityDecl {
+ kind:
+ EntityKind::External {
+ system_id,
+ public_id,
+ },
+ ..
+ }) = declaration
+ {
+ // XML 1.0 §3.1 (No External Entity References) and
+ // §4.4.4: forbidden even when a resolver is installed.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#CleanAttrVals
+ if context == ReferenceContext::Attribute {
+ return Err(
+ self.fatal("external entity reference in attribute value")
+ );
+ }
+ if let Some(resolver) = &self.entity_resolver {
+ let request = ExternalEntityRequest {
+ name,
+ system_id,
+ public_id: public_id.as_deref(),
+ };
+ if let Some(resolved) = resolver(request) {
+ Replacement::External(resolved)
+ } else {
+ return Err(self.fatal(format_args!(
+ "reference to external entity '{name}' is not supported"
+ )));
+ }
} else {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"reference to external entity '{name}' is not supported"
)));
}
+ } else if let Some(EntityDecl {
+ kind: EntityKind::Internal(value),
+ ..
+ }) = declaration
+ {
+ self.storage.charge(value.len())?;
+ if context == ReferenceContext::Attribute && value.contains("&#") {
+ // §4.5 constructs replacement text first; §3.3.3
+ // then treats its whitespace and literal '<' as
+ // replacement text, unlike a direct character ref.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#intern-replacement
+ Replacement::Decoded(
+ crate::parser_xmloxide::validation::dtd::expand_char_refs_only(
+ value,
+ self.storage,
+ )?,
+ )
+ } else {
+ Replacement::Borrowed(value.as_ref())
+ }
+ } else if self.recover || self.has_pe_references || self.has_external_dtd {
+ let diagnostic = ParseDiagnostic {
+ severity: ErrorSeverity::Warning,
+ message: S::freeze(self.storage.formatted(format_args!(
+ "unknown entity reference: &{name};"
+ ))?),
+ location: self.location(),
+ };
+ Values::push(&mut self.diagnostics, diagnostic)?;
+ continue;
} else {
- return Err(self.fatal(format!(
- "reference to external entity '{name}' is not supported"
- )));
+ return Err(
+ self.fatal(format_args!("unknown entity reference: &{name};"))
+ );
+ };
+ frame.position = i;
+ Values::push(&mut frames, frame)?;
+ let fold_line_ends = !matches!(replacement, Replacement::Decoded(_));
+ Values::push(
+ &mut frames,
+ Frame {
+ text: replacement,
+ position: 0,
+ fold_line_ends,
+ },
+ )?;
+ break;
+ }
+ } else {
+ // Scan once and copy literal spans rather than each character.
+ let start = i;
+ i += 1;
+ while i < bytes.len() && bytes[i] != b'&' && bytes[i] != b'<' {
+ i += 1;
+ }
+ if context == ReferenceContext::Attribute {
+ if bytes[start] == b'<' {
+ return Err(self.fatal("'<' not allowed in attribute values (from entity replacement text)"));
}
- } else if let Some(value) = self.entity_map.get(name).cloned() {
- self.expand_entity_text(&value)?
- } else if self.recover || self.has_pe_references || self.has_external_dtd {
- self.push_diagnostic(
- ErrorSeverity::Warning,
- format!("unknown entity reference: &{name};"),
- );
- String::new()
+ append_attribute_literal(
+ self.storage,
+ result,
+ &text[start..i],
+ frame.fold_line_ends,
+ )?;
} else {
- return Err(self.fatal(format!("unknown entity reference: &{name};")));
- };
- result.push_str(&expanded);
- }
- } else {
- // Regular character — copy as-is, handling multi-byte UTF-8
- let start = i;
- i += 1;
- // Skip continuation bytes
- while i < bytes.len() && bytes[i] & 0xC0 == 0x80 {
- i += 1;
- }
- if let Ok(s) = std::str::from_utf8(&bytes[start..i]) {
- result.push_str(s);
+ self.storage.append(result, &text[start..i])?;
+ }
}
}
}
- Ok(result)
- }
-
- /// Validates that an entity's replacement text matches the XML content
- /// production (XML 1.0 §4.3.2 WFC: Parsed Entity).
- ///
- /// Expands character references in the raw entity value, replaces entity
- /// references with placeholders, then wraps in a synthetic root element
- /// and parses. If parsing fails, the entity is not well-formed.
- fn validate_entity_content(&self, name: &str, raw_value: &str) -> Result<(), ParseError> {
- let replacement = crate::validation::dtd::expand_char_refs_only(raw_value);
-
- // If no '<', the text is just character data — always valid content.
- if !replacement.contains('<') {
- return Ok(());
- }
-
- let sanitized = crate::validation::dtd::replace_entity_refs(&replacement);
- let wrapped = format!("<_r>{sanitized}</_r>");
-
- let options = super::ParseOptions::default();
- if super::parse_str_with_options(&wrapped, &options).is_err() {
- return Err(self.fatal(format!(
- "entity '{name}' replacement text is not \
- well-formed XML content"
- )));
- }
-
Ok(())
}
+}
+impl<S: NameStorage> ParserInput<'_, S> {
// -- Attribute value parsing (XML 1.0 §3.3.3) --
/// Parses a quoted attribute value with entity resolution and
@@ -1493,18 +1498,22 @@
/// Uses bulk scanning to find the next `&`, `<`, or quote character,
/// then extracts safe chunks with a single `push_str()` instead of
/// processing character by character.
- pub fn parse_attribute_value(&mut self) -> Result<String, ParseError> {
+ pub fn parse_attribute_value(&mut self) -> Result<S::Text, ParseError<S>> {
let quote = self.next_byte()?;
if quote != b'"' && quote != b'\'' {
return Err(self.fatal("attribute value must be quoted"));
}
- let mut value = String::new();
+ let mut value = self.storage.text("")?;
loop {
// Bulk scan for the next interesting byte
- let safe_len = self.scan_attr_value(quote);
+ let safe_len = self.scan_attr_value(quote)?;
if safe_len > 0 {
let start = self.pos;
+ self.storage.charge(safe_len)?;
+ self.storage.charge(safe_len)?;
+ self.storage.charge(safe_len)?;
+ self.storage.charge(safe_len)?;
let chunk = std::str::from_utf8(&self.input[start..start + safe_len])
.map_err(|_| self.fatal("invalid UTF-8 in attribute value"))?;
// Fast byte-level pre-check: only validate when bytes suggest
@@ -1516,30 +1525,18 @@
if self.recover {
self.push_diagnostic(
ErrorSeverity::Error,
- format!("invalid XML character: U+{:04X}", bad as u32),
- );
+ format_args!("invalid XML character: U+{:04X}", bad as u32),
+ )?;
} else {
return Err(
- self.fatal(format!("invalid XML character: U+{:04X}", bad as u32))
+ self.fatal(format_args!("invalid XML character: U+{:04X}", bad as u32))
);
}
}
- // Normalize whitespace in chunk (XML 1.0 §3.3.3)
- if chunk
- .as_bytes()
- .iter()
- .any(|&b| b == b'\t' || b == b'\n' || b == b'\r')
- {
- for ch in chunk.chars() {
- match ch {
- '\t' | '\n' | '\r' => value.push(' '),
- _ => value.push(ch),
- }
- }
- } else {
- value.push_str(chunk);
- }
- self.advance_counting_lines(safe_len);
+ // XML 1.0 §§2.11, 3.3.3: one source CRLF is one space.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#AVNormalize
+ append_attribute_literal(self.storage, &mut value, chunk, true)?;
+ self.advance_counting_lines(safe_len)?;
}
if self.at_end() {
@@ -1548,57 +1545,45 @@
let b = self.input[self.pos];
if b == quote {
- self.advance(1);
+ self.advance(1)?;
break;
}
if b == b'&' {
- // Check if this is a DTD entity reference (not a built-in
- // or character reference) by peeking ahead.
- let is_custom_entity = self.input.get(self.pos + 1) != Some(&b'#')
- && !self.input[self.pos + 1..].starts_with(b"lt;")
- && !self.input[self.pos + 1..].starts_with(b"gt;")
- && !self.input[self.pos + 1..].starts_with(b"amp;")
- && !self.input[self.pos + 1..].starts_with(b"apos;")
- && !self.input[self.pos + 1..].starts_with(b"quot;");
- let resolved = self.parse_reference_into(&mut value)?;
- // WFC: No < in Attribute Values — entity replacement text
- // must not contain '<' (XML 1.0 §3.1). Built-in entity
- // < is explicitly excluded from this constraint.
- if is_custom_entity && resolved.contains('<') {
- return Err(
- self.fatal("'<' not allowed in attribute values (from entity expansion)")
- );
- }
+ self.parse_reference_in(&mut value, ReferenceContext::Attribute)?;
} else if b == b'<' {
return Err(self.fatal("'<' not allowed in attribute values"));
} else {
let ch = self.next_char()?;
// Normalize whitespace in attribute values (XML 1.0 §3.3.3)
if ch == '\r' || ch == '\n' || ch == '\t' {
- value.push(' ');
+ self.storage.append(&mut value, " ")?;
} else {
- value.push(ch);
+ let mut encoded = [0; 4];
+ self.storage
+ .append(&mut value, ch.encode_utf8(&mut encoded))?;
}
}
}
Ok(value)
}
+}
+impl<'a, S: NameStorage> ParserInput<'a, S> {
/// Parses a simple quoted value (single or double quotes, no entity
/// resolution).
- pub fn parse_quoted_value(&mut self) -> Result<String, ParseError> {
+ pub fn parse_quoted_value(&mut self) -> Result<&'a str, ParseError<S>> {
let quote = self.next_byte()?;
if quote != b'"' && quote != b'\'' {
return Err(self.fatal("expected quoted value"));
}
let start = self.pos;
- while !self.at_end() && self.peek() != Some(quote) {
- self.advance(1);
- }
+ let count =
+ self.scan_bytes(self.input.len() - self.pos, 1, |bytes, i| bytes[i] == quote)?;
+ self.advance_counting_lines(count)?;
+ self.storage.charge(count)?;
let value = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in quoted value"))?
- .to_string();
+ .map_err(|_| self.fatal("invalid UTF-8 in quoted value"))?;
self.expect_byte(quote)?;
Ok(value)
}
@@ -1606,21 +1591,44 @@
// -- Error helpers --
/// Creates a fatal `ParseError` at the current location.
- pub fn fatal(&self, message: impl Into<String>) -> ParseError {
- ParseError {
- message: message.into(),
- location: self.location(),
- diagnostics: self.diagnostics.clone(),
+ pub fn fatal(&self, message: impl std::fmt::Display) -> ParseError<S> {
+ let snapshot = || -> Result<_, crate::xml::dom::ParseError> {
+ let message = S::freeze(self.storage.formatted(format_args!("{message}"))?);
+ self.storage.charge(self.diagnostics.len())?;
+ let mut diagnostics = self.storage.values(self.diagnostics.len())?;
+ for diagnostic in &*self.diagnostics {
+ diagnostics.push(ParseDiagnostic {
+ severity: diagnostic.severity,
+ message: S::freeze(self.storage.text(diagnostic.message.as_ref())?),
+ location: diagnostic.location,
+ })?;
+ }
+ Ok((message, diagnostics))
+ };
+ match snapshot() {
+ Ok((message, diagnostics)) => ParseError::<S>::Syntax {
+ message,
+ location: self.location(),
+ diagnostics,
+ },
+ Err(error) => ParseError::<S>::Resource(error),
}
}
/// Appends a diagnostic (warning or recoverable error) to the list.
- pub fn push_diagnostic(&mut self, severity: ErrorSeverity, message: String) {
- self.diagnostics.push(ParseDiagnostic {
+ pub fn push_diagnostic(
+ &mut self,
+ severity: ErrorSeverity,
+ message: impl std::fmt::Display,
+ ) -> Result<(), ParseError<S>> {
+ let message = S::freeze(self.storage.formatted(format_args!("{message}"))?);
+ let diagnostic = ParseDiagnostic {
severity,
message,
location: self.location(),
- });
+ };
+ self.diagnostics.push(diagnostic)?;
+ Ok(())
}
}
@@ -1630,100 +1638,111 @@
/// Manages namespace scope for XML parsers.
///
-/// Maintains a stack of namespace binding frames that mirrors the element
-/// nesting. Each frame contains the `xmlns` declarations introduced on
-/// that element. A `HashMap` cache provides O(1) namespace resolution
-/// instead of walking the stack.
-pub(crate) struct NamespaceResolver {
- /// Stack of namespace binding frames. Each frame is a `Vec` of
- /// `(prefix, uri)` pairs where a `None` prefix represents the
- /// default namespace.
- stack: Vec<Vec<(Option<String>, String)>>,
- /// O(1) lookup cache for the default namespace (prefix = None).
- default_ns: Option<String>,
- /// O(1) lookup cache for prefixed namespaces. Uses `String` keys so
- /// lookups with `&str` work via the `Borrow` trait without allocation.
- prefixed_ns: HashMap<String, String>,
+/// Binding frames own previous values, not copies of the current cache. Undo
+/// moves those values back without rescanning all enclosing element scopes.
+pub(crate) struct NamespaceResolver<S: NameStorage = Native> {
+ stack: S::Values<S::Values<NamespaceUndo<S>>>,
+ default_ns: Option<S::Text>,
+ prefixed_ns: S::Names<S::Text, S::Text>,
+ storage: S,
+}
+
+enum NamespaceUndo<S: Storage> {
+ Default(Option<S::Text>),
+ Prefix(S::Text, Option<S::Text>),
}
/// The well-known XML namespace URI, pre-bound to the `xml` prefix.
pub(crate) const XML_NAMESPACE: &str = "http://www.w3.org/XML/1998/namespace";
-impl NamespaceResolver {
+impl NamespaceResolver<Native> {
/// Creates a new resolver with the `xml` prefix pre-bound.
- pub fn new() -> Self {
- let initial = vec![(Some("xml".to_string()), XML_NAMESPACE.to_string())];
- let mut prefixed_ns = HashMap::new();
- prefixed_ns.insert("xml".to_string(), XML_NAMESPACE.to_string());
- Self {
- stack: vec![initial],
+ pub fn new() -> Result<Self, crate::xml::dom::ParseError> {
+ Self::with_storage(Native)
+ }
+}
+
+impl<S: NameStorage> NamespaceResolver<S> {
+ pub fn with_storage(storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ let mut resolver = Self {
+ stack: storage.values(0)?,
default_ns: None,
- prefixed_ns,
- }
+ prefixed_ns: storage.names(),
+ storage,
+ };
+ resolver.push_scope()?;
+ resolver.bind(Some(storage.text("xml")?), storage.text(XML_NAMESPACE)?)?;
+ Ok(resolver)
}
/// Pushes a new (empty) namespace scope for an element.
- pub fn push_scope(&mut self) {
- self.stack.push(Vec::new());
+ pub fn push_scope(&mut self) -> Result<(), crate::xml::dom::ParseError> {
+ self.stack.push(self.storage.values(0)?)
}
/// Pops the current namespace scope, restoring previous bindings.
- pub fn pop_scope(&mut self) {
- if let Some(bindings) = self.stack.pop() {
- for (prefix, _uri) in bindings.iter().rev() {
- // Find previous binding in remaining stack
- let prev = self
- .stack
- .iter()
- .rev()
- .flat_map(|frame| frame.iter().rev())
- .find(|(p, _)| p == prefix)
- .map(|(_, u)| u.clone());
- match prefix {
- None => {
- self.default_ns = prev;
+ pub fn pop_scope(&mut self) -> Result<(), crate::xml::dom::ParseError> {
+ if let Some(mut bindings) = self.stack.pop() {
+ while let Some(binding) = bindings.pop() {
+ self.storage.charge(1)?;
+ match binding {
+ NamespaceUndo::Default(previous) => {
+ self.default_ns = previous;
}
- Some(pfx) => {
- if let Some(prev_uri) = prev {
- self.prefixed_ns.insert(pfx.clone(), prev_uri);
+ NamespaceUndo::Prefix(prefix, previous) => {
+ if let Some(uri) = previous {
+ self.prefixed_ns.insert(prefix, uri)?;
} else {
- self.prefixed_ns.remove(pfx);
+ self.prefixed_ns.remove(prefix.as_ref(), self.storage)?;
}
}
}
}
}
+ Ok(())
}
/// Binds a namespace prefix to a URI in the current scope.
///
/// Use `prefix = None` for the default namespace (`xmlns="..."`).
- pub fn bind(&mut self, prefix: Option<String>, uri: String) {
- if let Some(frame) = self.stack.last_mut() {
- frame.push((prefix.clone(), uri.clone()));
- }
- match prefix {
- None => {
- self.default_ns = Some(uri);
- }
- Some(pfx) => {
- self.prefixed_ns.insert(pfx, uri);
+ pub fn bind(
+ &mut self,
+ prefix: Option<S::Text>,
+ uri: S::Text,
+ ) -> Result<(), crate::xml::dom::ParseError> {
+ self.storage.charge(1)?;
+ let undo = match prefix {
+ None => NamespaceUndo::Default(self.default_ns.replace(uri)),
+ Some(prefix) => {
+ // The cache owns its key; the undo frame needs only that key,
+ // while the previous (possibly large) URI moves without a copy.
+ let key = self.storage.text(prefix.as_ref())?;
+ let previous = self.prefixed_ns.insert(prefix, uri)?;
+ NamespaceUndo::Prefix(key, previous)
}
+ };
+ if let Some(frame) = self.stack.last_mut() {
+ frame.push(undo)?;
}
+ Ok(())
}
/// Resolves a namespace prefix to its URI in O(1) time.
///
/// Use `prefix = None` to resolve the default namespace.
- pub fn resolve(&self, prefix: Option<&str>) -> Option<&str> {
- match prefix {
+ pub fn resolve(
+ &self,
+ prefix: Option<&str>,
+ ) -> Result<Option<&str>, crate::xml::dom::ParseError> {
+ self.storage.charge(1)?;
+ Ok(match prefix {
None => self.default_ns.as_deref().filter(|s| !s.is_empty()),
Some(pfx) => self
.prefixed_ns
- .get(pfx)
- .map(String::as_str)
+ .get(pfx, self.storage)?
+ .map(AsRef::as_ref)
.filter(|s| !s.is_empty()),
- }
+ })
}
}
@@ -1736,54 +1755,56 @@
/// The opening `<!--` must not have been consumed yet.
///
/// See XML 1.0 §2.5 production `[15]`.
-pub(crate) fn parse_comment_content(input: &mut ParserInput<'_>) -> Result<String, ParseError> {
+pub(crate) fn parse_comment_content<'a, S: NameStorage>(
+ input: &mut ParserInput<'a, S>,
+) -> Result<&'a str, ParseError<S>> {
input.expect_str(b"<!--")?;
+ let content_start = input.pos();
// Bulk scan for `--` (which is either `-->` end or illegal `--`)
- let mut content = String::new();
loop {
- match input.scan_for_2byte_terminator(b'-', b'-') {
+ match input.scan_for_2byte_terminator(b'-', b'-')? {
Some(safe_len) => {
- // Copy everything before the `--`
+ // Validate the original window before the `--`.
if safe_len > 0 {
let start = input.pos();
+ input.storage.charge(safe_len)?;
+ input.storage.charge(safe_len)?;
+ input.storage.charge(safe_len)?;
// Validate XML chars using byte-level pre-check
let has_bad =
may_contain_invalid_xml_chars(input.slice(start, start + safe_len));
let chunk = std::str::from_utf8(input.slice(start, start + safe_len))
- .map_err(|_| input.fatal("invalid UTF-8 in comment"))?
- .to_string();
- if has_bad {
- if let Some(bad) = find_invalid_xml_char(&chunk) {
- if input.recover() {
- input.push_diagnostic(
- ErrorSeverity::Error,
- format!("invalid XML character: U+{:04X}", bad as u32),
- );
- } else {
- return Err(input.fatal(format!(
- "invalid XML character: U+{:04X}",
- bad as u32
- )));
- }
+ .map_err(|_| input.fatal("invalid UTF-8 in comment"))?;
+ if has_bad && let Some(bad) = find_invalid_xml_char(chunk) {
+ if input.recover() {
+ input.push_diagnostic(
+ ErrorSeverity::Error,
+ format_args!("invalid XML character: U+{:04X}", bad as u32),
+ )?;
+ } else {
+ return Err(input.fatal(format_args!(
+ "invalid XML character: U+{:04X}",
+ bad as u32
+ )));
}
}
- content.push_str(&chunk);
- input.advance_counting_lines(safe_len);
+ input.advance_counting_lines(safe_len)?;
}
// Check if it's `-->` (end of comment) or just `--`
if input.looking_at(b"-->") {
- input.advance_counting_lines(3);
- break;
+ let content = std::str::from_utf8(input.slice(content_start, input.pos()))
+ .map_err(|_| input.fatal("invalid UTF-8 in comment"))?;
+ input.advance_counting_lines(3)?;
+ return Ok(content);
}
// Bare `--` inside comment
if input.recover() {
input.push_diagnostic(
ErrorSeverity::Error,
- "'--' not allowed inside comments".to_string(),
- );
- content.push_str("--");
- input.advance_counting_lines(2);
+ "'--' not allowed inside comments",
+ )?;
+ input.advance_counting_lines(2)?;
} else {
return Err(input.fatal("'--' not allowed inside comments"));
}
@@ -1793,8 +1814,6 @@
}
}
}
-
- Ok(content)
}
/// Parses a CDATA section (`<![CDATA[ ... ]]>`), returning the content text.
@@ -1802,32 +1821,34 @@
/// The opening `<![CDATA[` must not have been consumed yet.
///
/// See XML 1.0 §2.7 production `[18]`.
-pub(crate) fn parse_cdata_content(input: &mut ParserInput<'_>) -> Result<String, ParseError> {
+pub(crate) fn parse_cdata_content<'a, S: NameStorage>(
+ input: &mut ParserInput<'a, S>,
+) -> Result<&'a str, ParseError<S>> {
input.expect_str(b"<![CDATA[")?;
// Bulk scan for `]]>` terminator
- match input.scan_for_3byte_terminator(b']', b']', b'>') {
+ match input.scan_for_3byte_terminator(b']', b']', b'>')? {
Some(safe_len) => {
let start = input.pos();
+ input.storage.charge(safe_len)?;
+ input.storage.charge(safe_len)?;
+ input.storage.charge(safe_len)?;
let has_bad = may_contain_invalid_xml_chars(input.slice(start, start + safe_len));
let content = std::str::from_utf8(input.slice(start, start + safe_len))
- .map_err(|_| input.fatal("invalid UTF-8 in CDATA section"))?
- .to_string();
- if has_bad {
- if let Some(bad) = find_invalid_xml_char(&content) {
- if input.recover() {
- input.push_diagnostic(
- ErrorSeverity::Error,
- format!("invalid XML character: U+{:04X}", bad as u32),
- );
- } else {
- return Err(
- input.fatal(format!("invalid XML character: U+{:04X}", bad as u32))
- );
- }
+ .map_err(|_| input.fatal("invalid UTF-8 in CDATA section"))?;
+ if has_bad && let Some(bad) = find_invalid_xml_char(content) {
+ if input.recover() {
+ input.push_diagnostic(
+ ErrorSeverity::Error,
+ format_args!("invalid XML character: U+{:04X}", bad as u32),
+ )?;
+ } else {
+ return Err(
+ input.fatal(format_args!("invalid XML character: U+{:04X}", bad as u32))
+ );
}
}
- input.advance_counting_lines(safe_len + 3); // skip content + ]]>
+ input.advance_counting_lines(safe_len + 3)?; // skip content + ]]>
Ok(content)
}
None => Err(input.fatal("unexpected end of input in CDATA section")),
@@ -1840,9 +1861,9 @@
/// The opening `<?` must not have been consumed yet.
///
/// See XML 1.0 §2.6 production `[16]`.
-pub(crate) fn parse_pi_content(
- input: &mut ParserInput<'_>,
-) -> Result<(String, Option<String>), ParseError> {
+pub(crate) fn parse_pi_content<'a, S: NameStorage>(
+ input: &mut ParserInput<'a, S>,
+) -> Result<(&'a str, Option<&'a str>), ParseError<S>> {
input.expect_str(b"<?")?;
let target = input.parse_name()?;
@@ -1852,39 +1873,35 @@
}
// Namespaces in XML 1.0 §3: PI targets must be NCNames (no colons).
+ input.storage.charge(target.len())?;
if target.contains(':') {
return Err(input.fatal("PI target must not contain a colon"));
}
- let data = if input.skip_whitespace() {
+ let data = if input.skip_whitespace()? {
// Bulk scan for `?>` terminator
- match input.scan_for_2byte_terminator(b'?', b'>') {
+ match input.scan_for_2byte_terminator(b'?', b'>')? {
Some(data_len) => {
let start = input.pos();
+ input.storage.charge(data_len)?;
+ input.storage.charge(data_len)?;
+ input.storage.charge(data_len)?;
let has_bad = may_contain_invalid_xml_chars(input.slice(start, start + data_len));
let data = std::str::from_utf8(input.slice(start, start + data_len))
- .map_err(|_| input.fatal("invalid UTF-8 in processing instruction"))?
- .to_string();
- if has_bad {
- if let Some(bad) = find_invalid_xml_char(&data) {
- if input.recover() {
- input.push_diagnostic(
- ErrorSeverity::Error,
- format!("invalid XML character: U+{:04X}", bad as u32),
- );
- } else {
- return Err(
- input.fatal(format!("invalid XML character: U+{:04X}", bad as u32))
- );
- }
+ .map_err(|_| input.fatal("invalid UTF-8 in processing instruction"))?;
+ if has_bad && let Some(bad) = find_invalid_xml_char(data) {
+ if input.recover() {
+ input.push_diagnostic(
+ ErrorSeverity::Error,
+ format_args!("invalid XML character: U+{:04X}", bad as u32),
+ )?;
+ } else {
+ return Err(input
+ .fatal(format_args!("invalid XML character: U+{:04X}", bad as u32)));
}
}
- input.advance_counting_lines(data_len + 2); // skip data + ?>
- if data.is_empty() {
- None
- } else {
- Some(data)
- }
+ input.advance_counting_lines(data_len + 2)?; // skip data + ?>
+ if data.is_empty() { None } else { Some(data) }
}
None => {
return Err(input.fatal("unexpected end of input in processing instruction"));
@@ -1900,11 +1917,11 @@
/// Parsed XML declaration data.
#[derive(Debug, Clone)]
-pub(crate) struct XmlDeclaration {
+pub(crate) struct XmlDeclaration<'a> {
/// XML version (e.g. `"1.0"`).
- pub version: String,
+ pub version: &'a str,
/// Optional encoding declaration.
- pub encoding: Option<String>,
+ pub encoding: Option<&'a str>,
/// Optional standalone declaration.
pub standalone: Option<bool>,
}
@@ -1916,37 +1933,41 @@
/// verified by the caller via `looking_at`).
///
/// See XML 1.0 §2.8 production `[23]`.
-pub(crate) fn parse_xml_decl(input: &mut ParserInput<'_>) -> Result<XmlDeclaration, ParseError> {
+pub(crate) fn parse_xml_decl<'a, S: NameStorage>(
+ input: &mut ParserInput<'a, S>,
+) -> Result<XmlDeclaration<'a>, ParseError<S>> {
input.expect_str(b"<?xml")?;
input.skip_whitespace_required()?;
// version is required
input.expect_str(b"version")?;
- input.skip_whitespace();
+ input.skip_whitespace()?;
input.expect_byte(b'=')?;
- input.skip_whitespace();
+ input.skip_whitespace()?;
let version = input.parse_quoted_value()?;
// XML 1.0 §2.8: VersionNum ::= '1.' [0-9]+
- if !is_valid_version_num(&version) {
- return Err(input.fatal(format!("invalid version number: '{version}'")));
+ input.storage.charge(version.len())?;
+ if !is_valid_version_num(version) {
+ return Err(input.fatal(format_args!("invalid version number: '{version}'")));
}
// encoding is optional
- let had_ws = input.skip_whitespace();
+ let had_ws = input.skip_whitespace()?;
let encoding = if input.looking_at(b"encoding") {
if !had_ws {
return Err(input.fatal("whitespace required before encoding"));
}
input.expect_str(b"encoding")?;
- input.skip_whitespace();
+ input.skip_whitespace()?;
input.expect_byte(b'=')?;
- input.skip_whitespace();
+ input.skip_whitespace()?;
let enc = input.parse_quoted_value()?;
+ input.storage.charge(enc.len())?;
// XML 1.0 §4.3.3: EncName ::= [A-Za-z] ([A-Za-z0-9._] | '-')*
- if !is_valid_encoding_name(&enc) {
- return Err(input.fatal(format!("invalid encoding name: '{enc}'")));
+ if !is_valid_encoding_name(enc) {
+ return Err(input.fatal(format_args!("invalid encoding name: '{enc}'")));
}
Some(enc)
@@ -1958,17 +1979,17 @@
// If encoding was present, we need fresh whitespace before standalone.
// If encoding was absent, the whitespace consumed when looking for
// encoding already separates version from standalone.
- let had_ws2 = input.skip_whitespace() || (encoding.is_none() && had_ws);
+ let had_ws2 = input.skip_whitespace()? || (encoding.is_none() && had_ws);
let standalone = if input.looking_at(b"standalone") {
if !had_ws2 {
return Err(input.fatal("whitespace required before standalone"));
}
input.expect_str(b"standalone")?;
- input.skip_whitespace();
+ input.skip_whitespace()?;
input.expect_byte(b'=')?;
- input.skip_whitespace();
+ input.skip_whitespace()?;
let val = input.parse_quoted_value()?;
- match val.as_str() {
+ match val {
"yes" => Some(true),
"no" => Some(false),
_ => return Err(input.fatal("standalone must be 'yes' or 'no'")),
@@ -1977,7 +1998,7 @@
None
};
- input.skip_whitespace();
+ input.skip_whitespace()?;
input.expect_str(b"?>")?;
Ok(XmlDeclaration {
@@ -2024,9 +2045,9 @@
let mut input = ParserInput::new("abc");
assert_eq!(input.peek(), Some(b'a'));
assert_eq!(input.peek_at(1), Some(b'b'));
- input.advance(1);
+ input.advance(1).unwrap();
assert_eq!(input.peek(), Some(b'b'));
- input.advance(2);
+ input.advance(2).unwrap();
assert!(input.at_end());
}
@@ -2035,9 +2056,9 @@
let mut input = ParserInput::new("ab\ncd");
assert_eq!(input.location().line, 1);
assert_eq!(input.location().column, 1);
- input.advance(2); // past "ab"
+ input.advance(2).unwrap(); // past "ab"
assert_eq!(input.location().column, 3);
- input.advance(1); // past "\n"
+ input.advance(1).unwrap(); // past "\n"
assert_eq!(input.location().line, 2);
assert_eq!(input.location().column, 1);
}
@@ -2058,13 +2079,29 @@
}
#[test]
+ fn name_and_numeric_scanners_borrow_the_original_input_window() {
+ // These values are inspected before their actual ownership point.
+ // ASCII and Unicode paths must not make temporary name/digit copies.
+ for source in ["prefix:name ", "préfixe:имя "] {
+ let mut input = ParserInput::new(source);
+ let name = input.parse_name().unwrap();
+ assert_eq!(name.as_ptr(), source.as_ptr());
+ }
+ let source = "123456;";
+ let mut input = ParserInput::new(source);
+ let digits = input.take_while(|byte| byte.is_ascii_digit()).unwrap();
+ assert_eq!(digits.as_ptr(), source.as_ptr());
+ assert_eq!(digits, "123456");
+ }
+
+ #[test]
fn test_parse_name_length_limit() {
let long_name = "a".repeat(100);
let mut input = ParserInput::new(&long_name);
input.set_max_name_length(50);
let result = input.parse_name();
assert!(result.is_err());
- assert!(result.unwrap_err().message.contains("name length"));
+ assert!(result.unwrap_err().syntax_message().contains("name length"));
}
#[test]
@@ -2145,6 +2182,14 @@
}
#[test]
+ fn attribute_crlf_is_one_normalized_space() {
+ // XML 1.0 §§2.11, 3.3.3: EOL folding precedes attribute folding;
+ // referenced CR/LF remain distinct from literal source whitespace.
+ let mut input = ParserInput::new("'a\r\nb\rc\nd e'");
+ assert_eq!(input.parse_attribute_value().unwrap(), "a b c d\r\ne");
+ }
+
+ #[test]
fn test_parse_quoted_value() {
let mut input = ParserInput::new("'hello'");
let value = input.parse_quoted_value().unwrap();
@@ -2154,7 +2199,7 @@
#[test]
fn test_skip_whitespace() {
let mut input = ParserInput::new(" \t\n abc");
- assert!(input.skip_whitespace());
+ assert!(input.skip_whitespace().unwrap());
assert_eq!(input.peek(), Some(b'a'));
}
@@ -2168,12 +2213,211 @@
#[test]
fn test_take_while() {
let mut input = ParserInput::new("12345abc");
- let digits = input.take_while(|b| b.is_ascii_digit());
+ let digits = input.take_while(|b| b.is_ascii_digit()).unwrap();
assert_eq!(digits, "12345");
assert_eq!(input.peek(), Some(b'a'));
}
#[test]
+ fn lexical_text_windows_borrow_the_original_source() {
+ // Comments, CDATA, PI fields and declaration literals need no decoded
+ // scratch. Ownership belongs to the subsequent selected DOM storage.
+ let source = "<!--π text--><![CDATA[😀]]><?target data?>'value'";
+ let mut input = ParserInput::new(source);
+ let comment = parse_comment_content(&mut input).unwrap();
+ assert_eq!(comment, "π text");
+ assert_eq!(comment.as_ptr(), source[4..].as_ptr());
+ let cdata_start = input.pos() + "<![CDATA[".len();
+ let cdata = parse_cdata_content(&mut input).unwrap();
+ assert_eq!(cdata, "😀");
+ assert_eq!(cdata.as_ptr(), source[cdata_start..].as_ptr());
+ let pi_start = input.pos() + 2;
+ let (target, data) = parse_pi_content(&mut input).unwrap();
+ assert_eq!(target, "target");
+ assert_eq!(target.as_ptr(), source[pi_start..].as_ptr());
+ assert_eq!(data, Some("data"));
+ assert_eq!(
+ data.as_ref().unwrap().as_ptr(),
+ source[pi_start + 7..].as_ptr()
+ );
+ let quoted_start = input.pos() + 1;
+ let quoted = input.parse_quoted_value().unwrap();
+ assert_eq!(quoted, "value");
+ assert_eq!(quoted.as_ptr(), source[quoted_start..].as_ptr());
+ assert!(input.at_end());
+ }
+
+ #[cfg(feature = "xmldsig")]
+ #[test]
+ fn current_input_scans_admit_local_work_before_touching_source() {
+ // Native/current parsing uses the same scanner. Valid windows borrow
+ // source bytes even at zero owned capacity; local Work still gates the
+ // complete scan rather than charging only after finding a terminator.
+ use crate::xml::dom::storage::Accounted;
+ use structured_asn1::{
+ Encoding, Limits, Parser,
+ memory::{Memory, MemoryAccount, MemoryError},
+ };
+ let source = format!("<!--{}-->", "\u{e9} content ".repeat(1024));
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(0, 1 << 24)));
+ let mut input = ParserInput::with_storage(&source, Accounted(&memory)).unwrap();
+ let content = parse_comment_content(&mut input).unwrap();
+ assert_eq!(content.as_ptr(), source[4..].as_ptr());
+ assert_eq!(content, &source[4..source.len() - 3]);
+ assert_eq!(memory.used(), 0);
+ let needed = memory.work_used();
+ assert!(needed > source.len());
+ for limit in [0, needed as u64 - 1, needed as u64] {
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(0, 1 << 24)));
+ let parser = Parser::new(
+ Encoding::Der,
+ Limits {
+ max_work: limit,
+ ..Default::default()
+ },
+ memory.clone(),
+ );
+ let reader = parser.read(&[], 0).unwrap();
+ let work = reader.execution();
+ let mut input = ParserInput::with_storage(&source, Accounted(&work)).unwrap();
+ let result = parse_comment_content(&mut input);
+ if limit == needed as u64 {
+ assert_eq!(result.unwrap(), content);
+ } else {
+ assert!(matches!(
+ result,
+ Err(ParseError::<Accounted<'_, _>>::Resource(
+ crate::xml::dom::ParseError::SharedExecution(MemoryError::WorkExceeded)
+ ))
+ ));
+ if limit == 0 {
+ assert_eq!(input.pos(), 0);
+ }
+ }
+ assert_eq!(memory.used(), 0);
+ }
+ }
+
+ #[cfg(feature = "xmldsig")]
+ #[test]
+ fn current_input_diagnostics_keep_capacity_and_preserve_refusal() {
+ // A syntax failure owns its admitted diagnostic snapshot after the
+ // input is dropped. Refusal to construct that snapshot stays Resource.
+ use crate::xml::dom::storage::Accounted;
+ use structured_asn1::memory::{Memory, MemoryAccount, MemoryError};
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 24)));
+ let mut input = ParserInput::with_storage("", Accounted(&memory)).unwrap();
+ input
+ .push_diagnostic(ErrorSeverity::Warning, "preceding warning")
+ .unwrap();
+ let error = input.fatal(format_args!("invalid name: {}", "\u{540d}"));
+ let ParseError::<Accounted<'_, _>>::Syntax {
+ message,
+ location,
+ diagnostics,
+ } = &error
+ else {
+ panic!("paid syntax snapshot");
+ };
+ assert_eq!(message.as_ref(), "invalid name: \u{540d}");
+ assert_eq!(*location, input.location());
+ assert_eq!(diagnostics.len(), 1);
+ assert_eq!(diagnostics[0].message.as_ref(), "preceding warning");
+ drop(input);
+ assert!(memory.used() > 0);
+ drop(error);
+ assert_eq!(memory.used(), 0);
+
+ let denied = Memory::new(std::sync::Arc::new(MemoryAccount::new(0, 1 << 20)));
+ let input = ParserInput::with_storage("", Accounted(&denied)).unwrap();
+ assert!(matches!(
+ input.fatal("diagnostic"),
+ ParseError::<Accounted<'_, _>>::Resource(crate::xml::dom::ParseError::SharedExecution(
+ MemoryError::LimitExceeded
+ ))
+ ));
+ assert_eq!(denied.used(), 0);
+ }
+
+ #[cfg(feature = "xmldsig")]
+ #[test]
+ fn current_attribute_entities_keep_storage_and_refuse_before_parsing() {
+ // The original catalogue, expansion stack and final attribute all use
+ // the actual execution. Expansion does not clone each retained value
+ // or build a second result before appending it to the attribute.
+ use crate::xml::dom::storage::Accounted;
+ use structured_asn1::{
+ Encoding, Limits, Parser,
+ memory::{Memory, MemoryAccount, MemoryError, Work},
+ };
+ const SOURCE: &str = "'&a; A & &b;'";
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 24)));
+ let parser = Parser::new(
+ Encoding::Der,
+ Limits {
+ max_work: 1 << 20,
+ ..Default::default()
+ },
+ memory.clone(),
+ );
+ let reader = parser.read(&[], 0).unwrap();
+ let work = reader.execution();
+ let storage = Accounted(&work);
+ let mut input = ParserInput::with_storage(SOURCE, storage).unwrap();
+ Names::insert(
+ &mut input.entity_map,
+ storage.text("a").unwrap(),
+ EntityDecl {
+ name: storage.text("").unwrap(),
+ kind: EntityKind::Internal(storage.text("hello").unwrap()),
+ },
+ )
+ .unwrap();
+ Names::insert(
+ &mut input.entity_map,
+ storage.text("b").unwrap(),
+ EntityDecl {
+ name: storage.text("").unwrap(),
+ kind: EntityKind::Internal(storage.text("&a;").unwrap()),
+ },
+ )
+ .unwrap();
+ let retained = memory.used();
+ assert!(retained > 0);
+ let result = input.parse_attribute_value().unwrap();
+ assert_eq!(result.as_ref(), "hello A & hello");
+ assert!(memory.used() > retained);
+ drop(result);
+ assert_eq!(memory.used(), retained);
+ input.restore_position(SavedPosition {
+ pos: 0,
+ line: 1,
+ column: 1,
+ });
+ work.charge_work((1 << 20) - memory.work_used() as u64)
+ .unwrap();
+ assert!(matches!(
+ input.parse_attribute_value(),
+ Err(ParseError::<Accounted<'_, _>>::Resource(
+ crate::xml::dom::ParseError::SharedExecution(MemoryError::WorkExceeded)
+ ))
+ ));
+ assert_eq!(input.pos(), 0);
+ drop(input);
+ assert_eq!(memory.used(), 0);
+
+ let denied = Memory::new(std::sync::Arc::new(MemoryAccount::new(0, 1 << 20)));
+ let mut input = ParserInput::with_storage("'payload'", Accounted(&denied)).unwrap();
+ assert!(matches!(
+ input.parse_attribute_value(),
+ Err(ParseError::<Accounted<'_, _>>::Resource(
+ crate::xml::dom::ParseError::SharedExecution(MemoryError::LimitExceeded)
+ ))
+ ));
+ assert_eq!(denied.used(), 0);
+ }
+
+ #[test]
fn test_split_name() {
assert_eq!(split_name("foo:bar"), (Some("foo"), "bar"));
assert_eq!(split_name("bar"), (None, "bar"));
@@ -2182,37 +2426,94 @@
#[test]
fn test_namespace_resolver() {
- let mut ns = NamespaceResolver::new();
+ let mut ns = NamespaceResolver::new().unwrap();
// xml prefix is pre-bound
- assert_eq!(ns.resolve(Some("xml")), Some(XML_NAMESPACE));
- assert_eq!(ns.resolve(None), None); // no default namespace
+ assert_eq!(ns.resolve(Some("xml")).unwrap(), Some(XML_NAMESPACE));
+ assert_eq!(ns.resolve(None).unwrap(), None); // no default namespace
- ns.push_scope();
- ns.bind(None, "http://default".to_string());
- ns.bind(Some("foo".to_string()), "http://foo".to_string());
-
- assert_eq!(ns.resolve(None), Some("http://default"));
- assert_eq!(ns.resolve(Some("foo")), Some("http://foo"));
-
- ns.pop_scope();
- assert_eq!(ns.resolve(None), None);
- assert_eq!(ns.resolve(Some("foo")), None);
+ ns.push_scope().unwrap();
+ ns.bind(None, "http://default".to_string()).unwrap();
+ ns.bind(Some("foo".to_string()), "http://foo".to_string())
+ .unwrap();
+
+ assert_eq!(ns.resolve(None).unwrap(), Some("http://default"));
+ assert_eq!(ns.resolve(Some("foo")).unwrap(), Some("http://foo"));
+
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), None);
+ assert_eq!(ns.resolve(Some("foo")).unwrap(), None);
+ }
+
+ #[cfg(feature = "xmldsig")]
+ #[test]
+ fn namespace_undo_moves_uris_and_keeps_the_current_work_domain() {
+ // Undo must restore the existing URI allocation, not clone a large
+ // value or rescan enclosing frames. Query refusal remains terminal.
+ use crate::xml::dom::storage::Accounted;
+ use structured_asn1::{
+ Encoding, Limits, Parser,
+ memory::{Memory, MemoryAccount, MemoryError, Work},
+ };
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 24)));
+ let parser = Parser::new(
+ Encoding::Der,
+ Limits {
+ max_work: 1 << 20,
+ ..Default::default()
+ },
+ memory.clone(),
+ );
+ let reader = parser.read(&[], 0).unwrap();
+ let work = reader.execution();
+ let storage = Accounted(&work);
+ let mut resolver = NamespaceResolver::with_storage(storage).unwrap();
+ resolver.push_scope().unwrap();
+ let original_uri = storage.text("urn:retained:outer").unwrap();
+ let original_pointer = original_uri.as_ptr();
+ resolver
+ .bind(Some(storage.text("p").unwrap()), original_uri)
+ .unwrap();
+ resolver.push_scope().unwrap();
+ resolver
+ .bind(
+ Some(storage.text("p").unwrap()),
+ storage.text("urn:inner").unwrap(),
+ )
+ .unwrap();
+ assert_eq!(resolver.resolve(Some("p")).unwrap(), Some("urn:inner"));
+ resolver.pop_scope().unwrap();
+ let restored = resolver.resolve(Some("p")).unwrap().unwrap();
+ assert_eq!(restored, "urn:retained:outer");
+ assert_eq!(restored.as_ptr(), original_pointer);
+ assert!(memory.used() > 0);
+ // Refused admission is not consumption. Spend the remaining local
+ // allowance exactly before testing the next borrowed query.
+ work.charge_work((1 << 20) - memory.work_used() as u64)
+ .unwrap();
+ assert!(matches!(
+ resolver.resolve(Some("p")),
+ Err(crate::xml::dom::ParseError::SharedExecution(
+ MemoryError::WorkExceeded
+ ))
+ ));
+ drop(resolver);
+ assert_eq!(memory.used(), 0);
}
#[test]
fn test_namespace_undeclare_default() {
- let mut ns = NamespaceResolver::new();
- ns.push_scope();
- ns.bind(None, "http://default".to_string());
- assert_eq!(ns.resolve(None), Some("http://default"));
-
- ns.push_scope();
- ns.bind(None, String::new()); // xmlns=""
- assert_eq!(ns.resolve(None), None);
+ let mut ns = NamespaceResolver::new().unwrap();
+ ns.push_scope().unwrap();
+ ns.bind(None, "http://default".to_string()).unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), Some("http://default"));
+
+ ns.push_scope().unwrap();
+ ns.bind(None, String::new()).unwrap(); // xmlns=""
+ assert_eq!(ns.resolve(None).unwrap(), None);
- ns.pop_scope();
- assert_eq!(ns.resolve(None), Some("http://default"));
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), Some("http://default"));
}
#[test]
@@ -2234,7 +2535,7 @@
let mut input = ParserInput::new("<?target data?>");
let (target, data) = parse_pi_content(&mut input).unwrap();
assert_eq!(target, "target");
- assert_eq!(data.as_deref(), Some("data"));
+ assert_eq!(data, Some("data"));
}
#[test]
@@ -2250,7 +2551,7 @@
let mut input = ParserInput::new("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
let decl = parse_xml_decl(&mut input).unwrap();
assert_eq!(decl.version, "1.0");
- assert_eq!(decl.encoding.as_deref(), Some("UTF-8"));
+ assert_eq!(decl.encoding, Some("UTF-8"));
assert_eq!(decl.standalone, None);
}
@@ -2301,7 +2602,10 @@
input.set_max_depth(1);
assert!(input.increment_depth().is_ok()); // depth = 1
let err = input.increment_depth().unwrap_err();
- assert!(err.message.contains("maximum nesting depth exceeded"));
+ assert!(
+ err.syntax_message()
+ .contains("maximum nesting depth exceeded")
+ );
}
#[test]
@@ -2310,7 +2614,10 @@
let mut input = ParserInput::new("");
input.set_max_depth(0);
let err = input.increment_depth().unwrap_err();
- assert!(err.message.contains("maximum nesting depth exceeded"));
+ assert!(
+ err.syntax_message()
+ .contains("maximum nesting depth exceeded")
+ );
}
#[test]
@@ -2351,7 +2658,10 @@
assert!(input.parse_reference().is_ok()); // expansion 2
assert!(input.parse_reference().is_ok()); // expansion 3
let err = input.parse_reference().unwrap_err();
- assert!(err.message.contains("entity expansion limit exceeded"));
+ assert!(
+ err.syntax_message()
+ .contains("entity expansion limit exceeded")
+ );
}
#[test]
@@ -2360,7 +2670,10 @@
let mut input = ParserInput::new("&");
input.set_max_entity_expansions(0);
let err = input.parse_reference().unwrap_err();
- assert!(err.message.contains("entity expansion limit exceeded"));
+ assert!(
+ err.syntax_message()
+ .contains("entity expansion limit exceeded")
+ );
}
#[test]
@@ -2369,7 +2682,10 @@
input.set_max_entity_expansions(1);
assert!(input.parse_reference().is_ok());
let err = input.parse_reference().unwrap_err();
- assert!(err.message.contains("entity expansion limit exceeded"));
+ assert!(
+ err.syntax_message()
+ .contains("entity expansion limit exceeded")
+ );
}
#[test]
@@ -2380,29 +2696,34 @@
assert!(input.parse_reference().is_ok()); // A → A
assert!(input.parse_reference().is_ok()); // B → B
let err = input.parse_reference().unwrap_err();
- assert!(err.message.contains("entity expansion limit exceeded"));
+ assert!(
+ err.syntax_message()
+ .contains("entity expansion limit exceeded")
+ );
}
#[test]
fn test_entity_expansion_limit_via_parse_str() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
// Test through the high-level API: document with many char refs
let refs: String = (0..50).map(|_| "A").collect();
let xml = format!("<r>{refs}</r>");
let opts = ParseOptions::default().max_entity_expansions(10);
let result = parse_str_with_options(&xml, &opts);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("entity expansion limit"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("entity expansion limit")
+ );
}
// -- Entity expansion: DTD internal entity recursion --
#[test]
fn test_entity_expansion_dtd_internal_entity() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
let xml = r#"<!DOCTYPE r [
<!ENTITY greet "Hello">
]>
@@ -2414,7 +2735,7 @@
#[test]
fn test_entity_expansion_nested_dtd_entities() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
// Entity "b" references entity "a". The nested reference is parsed
// as content (XML 1.0 §4.4), so the entity expansion is visible
// through text_content.
@@ -2430,7 +2751,7 @@
#[test]
fn test_entity_expansion_limit_nested_dtd_entities_in_attributes() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
// In attribute values, entity references ARE fully expanded through
// parse_reference_into + expand_entity_text. Each expansion counts
// against the limit. Chain: c -> 3*b -> 9*a = 13 total expansions.
@@ -2443,17 +2764,19 @@
let opts = ParseOptions::default().max_entity_expansions(5);
let result = parse_str_with_options(xml, &opts);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("entity expansion limit"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("entity expansion limit")
+ );
}
// -- Billion laughs style attack (exponential entity expansion) --
#[test]
fn test_billion_laughs_entity_bomb_in_attribute() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
// Classic billion laughs pattern in an attribute value where
// entities ARE fully expanded. Each entity references the
// previous one multiple times, causing exponential expansion.
@@ -2470,15 +2793,15 @@
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
- err.message.contains("entity expansion limit"),
+ err.syntax_message().contains("entity expansion limit"),
"billion laughs should be caught by expansion limit, got: {}",
- err.message
+ err.syntax_message()
);
}
#[test]
fn test_billion_laughs_in_text_content_with_markup() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
// When entity replacement text contains '<', the parser must expand
// it (to validate the markup), which triggers entity expansion
// counting. This tests the billion laughs pattern for text content
@@ -2494,9 +2817,9 @@
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
- err.message.contains("entity expansion limit"),
+ err.syntax_message().contains("entity expansion limit"),
"billion laughs with markup should be caught, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2504,7 +2827,7 @@
#[test]
fn test_xxe_external_entity_rejected_by_default() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
let xml = r#"<!DOCTYPE r [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
@@ -2513,15 +2836,15 @@
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
- err.message.contains("external entity"),
+ err.syntax_message().contains("external entity"),
"XXE should be rejected by default, got: {}",
- err.message
+ err.syntax_message()
);
}
#[test]
fn test_xxe_external_entity_with_public_id_rejected() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
let xml = r#"<!DOCTYPE r [
<!ENTITY xxe PUBLIC "-//Evil//EN" "http://evil.com/payload">
]>
@@ -2530,15 +2853,15 @@
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
- err.message.contains("external entity"),
+ err.syntax_message().contains("external entity"),
"XXE with PUBLIC id should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
#[test]
fn test_xxe_external_entity_in_attribute_rejected() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
let xml = r#"<!DOCTYPE r [
<!ENTITY xxe SYSTEM "file:///etc/shadow">
]>
@@ -2549,7 +2872,7 @@
#[test]
fn test_xxe_multiple_external_entities_all_rejected() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
// Even if one entity is internal, the external one should fail
let xml = r#"<!DOCTYPE r [
<!ENTITY safe "ok">
@@ -2568,9 +2891,9 @@
let mut input = ParserInput::new("�");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("valid XML character"),
+ err.syntax_message().contains("valid XML character"),
"null char ref should be rejected as invalid XML char, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2579,9 +2902,9 @@
let mut input = ParserInput::new("�");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("valid XML character"),
+ err.syntax_message().contains("valid XML character"),
"� should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2619,17 +2942,17 @@
let mut input = ParserInput::new("�");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("invalid character reference"),
+ err.syntax_message().contains("invalid character reference"),
"surrogate � should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
let mut input = ParserInput::new("�");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("invalid character reference"),
+ err.syntax_message().contains("invalid character reference"),
"surrogate � should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2639,17 +2962,17 @@
let mut input = ParserInput::new("");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("valid XML character"),
+ err.syntax_message().contains("valid XML character"),
" should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
let mut input = ParserInput::new("");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("valid XML character"),
+ err.syntax_message().contains("valid XML character"),
" should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2668,9 +2991,9 @@
let mut input = ParserInput::new("�");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("invalid character reference"),
+ err.syntax_message().contains("invalid character reference"),
"codepoint beyond Unicode range should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2680,9 +3003,10 @@
let mut input = ParserInput::new("�");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("invalid decimal character reference"),
+ err.syntax_message()
+ .contains("invalid decimal character reference"),
"overflowing decimal char ref should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2692,9 +3016,10 @@
let mut input = ParserInput::new("�");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("invalid hex character reference"),
+ err.syntax_message()
+ .contains("invalid hex character reference"),
"overflowing hex char ref should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2703,9 +3028,10 @@
let mut input = ParserInput::new("&#;");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("empty decimal character reference"),
+ err.syntax_message()
+ .contains("empty decimal character reference"),
"empty decimal ref should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2714,9 +3040,10 @@
let mut input = ParserInput::new("&#x;");
let err = input.parse_reference().unwrap_err();
assert!(
- err.message.contains("empty hex character reference"),
+ err.syntax_message()
+ .contains("empty hex character reference"),
"empty hex ref should be rejected, got: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2760,7 +3087,7 @@
input.set_max_name_length(50);
let result = input.parse_name();
assert!(result.is_err());
- assert!(result.unwrap_err().message.contains("name length"));
+ assert!(result.unwrap_err().syntax_message().contains("name length"));
}
#[test]
@@ -2806,7 +3133,7 @@
input.set_max_name_length(50);
let result = input.parse_name_eq("something");
assert!(result.is_err());
- assert!(result.unwrap_err().message.contains("name length"));
+ assert!(result.unwrap_err().syntax_message().contains("name length"));
}
#[test]
@@ -2817,7 +3144,59 @@
input.set_max_name_length(50);
let result = input.parse_name_eq_parts(None, "something");
assert!(result.is_err());
- assert!(result.unwrap_err().message.contains("name length"));
+ assert!(result.unwrap_err().syntax_message().contains("name length"));
+ }
+
+ #[test]
+ fn matching_names_share_grammar_limits_and_source_windows() {
+ // End-tag shortcuts must consume exactly the same Name as the ordinary
+ // scanner, including mixed ASCII/Unicode names and rejected starts.
+ for source in [
+ "ns:local>",
+ "n\u{e9}:\u{540d}>",
+ "\u{540d}>",
+ "a:b:c>",
+ "9bad>",
+ "",
+ ] {
+ for limit in [0, 1, 4, 64] {
+ let mut ordinary = ParserInput::new(source);
+ ordinary.set_max_name_length(limit);
+ let expected = ordinary.parse_name();
+ let location = ordinary.location();
+ let mut exact = ParserInput::new(source);
+ exact.set_max_name_length(limit);
+ let actual = exact.parse_name_eq("different");
+ let mut parts = ParserInput::new(source);
+ parts.set_max_name_length(limit);
+ let split = parts.parse_name_eq_parts(Some("different"), "local");
+ assert_eq!(exact.location(), location);
+ assert_eq!(parts.location(), location);
+ match expected {
+ Ok(name) => {
+ let actual = actual.unwrap().unwrap();
+ let split = split.unwrap().unwrap();
+ assert_eq!(actual, name);
+ assert_eq!(split, name);
+ assert_eq!(actual.as_ptr(), name.as_ptr());
+ assert_eq!(split.as_ptr(), name.as_ptr());
+ }
+ Err(error) => {
+ assert_eq!(actual.unwrap_err().syntax_message(), error.syntax_message());
+ assert_eq!(split.unwrap_err().syntax_message(), error.syntax_message());
+ }
+ }
+ }
+ }
+ for (source, prefix, local) in [
+ ("ns:local>", Some("ns"), "local"),
+ ("n\u{e9}:\u{540d}>", Some("n\u{e9}"), "\u{540d}"),
+ ("\u{540d}>", None, "\u{540d}"),
+ ] {
+ let mut input = ParserInput::new(source);
+ assert_eq!(input.parse_name_eq_parts(prefix, local).unwrap(), None);
+ assert_eq!(input.peek(), Some(b'>'));
+ }
}
// -- Comment boundary edge cases --
@@ -2829,7 +3208,10 @@
let result = parse_comment_content(&mut input);
assert!(result.is_err());
assert!(
- result.unwrap_err().message.contains("'--' not allowed"),
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("'--' not allowed"),
"double dash inside comment should be rejected"
);
}
@@ -2848,10 +3230,12 @@
let mut input = ParserInput::new("<!-- no end");
let result = parse_comment_content(&mut input);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("unexpected end of input in comment"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("unexpected end of input in comment")
+ );
}
#[test]
@@ -2893,10 +3277,12 @@
let mut input = ParserInput::new("<![CDATA[no end");
let result = parse_cdata_content(&mut input);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("unexpected end of input in CDATA"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("unexpected end of input in CDATA")
+ );
}
#[test]
@@ -2937,10 +3323,12 @@
let mut input = ParserInput::new("<?xml data?>");
let result = parse_pi_content(&mut input);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("PI target 'xml' is reserved"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("PI target 'xml' is reserved")
+ );
}
#[test]
@@ -2959,10 +3347,12 @@
let mut input = ParserInput::new("<?ns:target data?>");
let result = parse_pi_content(&mut input);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("must not contain a colon"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("must not contain a colon")
+ );
}
#[test]
@@ -2988,10 +3378,12 @@
let mut input = ParserInput::new("\"abc<def\"");
let result = input.parse_attribute_value();
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("'<' not allowed in attribute values"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("'<' not allowed in attribute values")
+ );
}
#[test]
@@ -2999,10 +3391,12 @@
let mut input = ParserInput::new("\"no closing quote");
let result = input.parse_attribute_value();
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("unexpected end of input"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("unexpected end of input")
+ );
}
#[test]
@@ -3010,10 +3404,12 @@
let mut input = ParserInput::new("unquoted");
let result = input.parse_attribute_value();
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("attribute value must be quoted"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("attribute value must be quoted")
+ );
}
#[test]
@@ -3024,22 +3420,19 @@
}
#[test]
- fn test_attribute_value_entity_with_less_than_rejected() {
- use crate::parser::{parse_str_with_options, ParseOptions};
- // Entity whose replacement text contains '<' is rejected
- // per WFC: No < in Attribute Values
+ fn attribute_entity_builtin_less_than_is_not_literal_replacement_markup() {
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
+ // XML 1.0 §§3.1, 4.5: the replacement text retains <, not a
+ // literal '<'; recursive attribute processing must accept it.
let xml = r#"<!DOCTYPE r [
<!ENTITY bad "a<b">
]>
<r a="&bad;"/>"#;
- // Note: < in the entity value is expanded to <, which is then
- // found in the attribute value. Whether this triggers the WFC check
- // depends on the implementation's handling of nested expansion.
- // This test verifies the parser has SOME handling for this case.
- let result = parse_str_with_options(xml, &ParseOptions::default());
- // The entity "bad" contains "<" which expands to "<".
- // This "<" in attribute value should be caught.
- assert!(result.is_err());
+ let document = parse_str_with_options(xml, &ParseOptions::default()).unwrap();
+ assert_eq!(
+ document.attribute(document.root_element().unwrap(), "a"),
+ Some("a<b")
+ );
}
// -- Invalid XML character detection --
@@ -3076,10 +3469,12 @@
let mut input = ParserInput::new(input_bytes);
let result = input.next_char();
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("invalid XML character"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("invalid XML character")
+ );
}
#[test]
@@ -3101,138 +3496,150 @@
fn test_scan_char_data_cdata_end_marker() {
// `]]>` in character data is not allowed — the scanner should stop before it
let input = ParserInput::new("text]]>more");
- let len = input.scan_char_data();
+ let len = input.scan_char_data().unwrap();
assert_eq!(len, 4); // stops before `]]>`
}
#[test]
fn test_scan_char_data_empty() {
let input = ParserInput::new("<");
- assert_eq!(input.scan_char_data(), 0);
+ assert_eq!(input.scan_char_data().unwrap(), 0);
}
#[test]
fn test_scan_char_data_stops_at_ampersand() {
let input = ParserInput::new("text&ref;");
- assert_eq!(input.scan_char_data(), 4);
+ assert_eq!(input.scan_char_data().unwrap(), 4);
}
#[test]
fn test_scan_char_data_stops_at_less_than() {
let input = ParserInput::new("text<elem");
- assert_eq!(input.scan_char_data(), 4);
+ assert_eq!(input.scan_char_data().unwrap(), 4);
}
#[test]
fn test_scan_for_2byte_terminator_at_end() {
// Input too short for any 2-byte terminator
let input = ParserInput::new("x");
- assert_eq!(input.scan_for_2byte_terminator(b'-', b'-'), None);
+ assert_eq!(input.scan_for_2byte_terminator(b'-', b'-').unwrap(), None);
}
#[test]
fn test_scan_for_2byte_terminator_exact_2_bytes() {
let input = ParserInput::new("--");
- assert_eq!(input.scan_for_2byte_terminator(b'-', b'-'), Some(0));
+ assert_eq!(
+ input.scan_for_2byte_terminator(b'-', b'-').unwrap(),
+ Some(0)
+ );
}
#[test]
fn test_scan_for_3byte_terminator_at_end() {
let input = ParserInput::new("]]");
- assert_eq!(input.scan_for_3byte_terminator(b']', b']', b'>'), None);
+ assert_eq!(
+ input.scan_for_3byte_terminator(b']', b']', b'>').unwrap(),
+ None
+ );
}
#[test]
fn test_scan_for_3byte_terminator_exact_3_bytes() {
let input = ParserInput::new("]]>");
- assert_eq!(input.scan_for_3byte_terminator(b']', b']', b'>'), Some(0));
+ assert_eq!(
+ input.scan_for_3byte_terminator(b']', b']', b'>').unwrap(),
+ Some(0)
+ );
}
// -- Namespace resolution edge cases --
#[test]
fn test_namespace_resolver_nested_override() {
- let mut ns = NamespaceResolver::new();
- ns.push_scope();
- ns.bind(Some("p".to_string()), "http://outer".to_string());
- assert_eq!(ns.resolve(Some("p")), Some("http://outer"));
+ let mut ns = NamespaceResolver::new().unwrap();
+ ns.push_scope().unwrap();
+ ns.bind(Some("p".to_string()), "http://outer".to_string())
+ .unwrap();
+ assert_eq!(ns.resolve(Some("p")).unwrap(), Some("http://outer"));
// Inner scope overrides the same prefix
- ns.push_scope();
- ns.bind(Some("p".to_string()), "http://inner".to_string());
- assert_eq!(ns.resolve(Some("p")), Some("http://inner"));
+ ns.push_scope().unwrap();
+ ns.bind(Some("p".to_string()), "http://inner".to_string())
+ .unwrap();
+ assert_eq!(ns.resolve(Some("p")).unwrap(), Some("http://inner"));
// After popping, outer binding is restored
- ns.pop_scope();
- assert_eq!(ns.resolve(Some("p")), Some("http://outer"));
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(Some("p")).unwrap(), Some("http://outer"));
- ns.pop_scope();
- assert_eq!(ns.resolve(Some("p")), None);
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(Some("p")).unwrap(), None);
}
#[test]
fn test_namespace_resolver_default_ns_override_and_restore() {
- let mut ns = NamespaceResolver::new();
- ns.push_scope();
- ns.bind(None, "http://a".to_string());
- ns.push_scope();
- ns.bind(None, "http://b".to_string());
- assert_eq!(ns.resolve(None), Some("http://b"));
- ns.pop_scope();
- assert_eq!(ns.resolve(None), Some("http://a"));
- ns.pop_scope();
- assert_eq!(ns.resolve(None), None);
+ let mut ns = NamespaceResolver::new().unwrap();
+ ns.push_scope().unwrap();
+ ns.bind(None, "http://a".to_string()).unwrap();
+ ns.push_scope().unwrap();
+ ns.bind(None, "http://b".to_string()).unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), Some("http://b"));
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), Some("http://a"));
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), None);
}
#[test]
fn test_namespace_resolver_undeclare_default_then_redeclare() {
- let mut ns = NamespaceResolver::new();
- ns.push_scope();
- ns.bind(None, "http://ns".to_string());
- ns.push_scope();
- ns.bind(None, String::new()); // undeclare
- assert_eq!(ns.resolve(None), None);
- ns.push_scope();
- ns.bind(None, "http://new".to_string()); // re-declare
- assert_eq!(ns.resolve(None), Some("http://new"));
- ns.pop_scope();
- assert_eq!(ns.resolve(None), None); // back to undeclared
- ns.pop_scope();
- assert_eq!(ns.resolve(None), Some("http://ns")); // original
- ns.pop_scope();
- assert_eq!(ns.resolve(None), None);
+ let mut ns = NamespaceResolver::new().unwrap();
+ ns.push_scope().unwrap();
+ ns.bind(None, "http://ns".to_string()).unwrap();
+ ns.push_scope().unwrap();
+ ns.bind(None, String::new()).unwrap(); // undeclare
+ assert_eq!(ns.resolve(None).unwrap(), None);
+ ns.push_scope().unwrap();
+ ns.bind(None, "http://new".to_string()).unwrap(); // re-declare
+ assert_eq!(ns.resolve(None).unwrap(), Some("http://new"));
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), None); // back to undeclared
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), Some("http://ns")); // original
+ ns.pop_scope().unwrap();
+ assert_eq!(ns.resolve(None).unwrap(), None);
}
#[test]
fn test_namespace_resolver_xml_prefix_always_bound() {
- let ns = NamespaceResolver::new();
- assert_eq!(ns.resolve(Some("xml")), Some(XML_NAMESPACE));
+ let ns = NamespaceResolver::new().unwrap();
+ assert_eq!(ns.resolve(Some("xml")).unwrap(), Some(XML_NAMESPACE));
}
#[test]
fn test_namespace_resolver_unbound_prefix() {
- let ns = NamespaceResolver::new();
- assert_eq!(ns.resolve(Some("foo")), None);
- assert_eq!(ns.resolve(Some("xmlns")), None);
+ let ns = NamespaceResolver::new().unwrap();
+ assert_eq!(ns.resolve(Some("foo")).unwrap(), None);
+ assert_eq!(ns.resolve(Some("xmlns")).unwrap(), None);
}
#[test]
fn test_namespace_resolver_many_scopes() {
// Stress test: push and pop many scopes with bindings
- let mut ns = NamespaceResolver::new();
+ let mut ns = NamespaceResolver::new().unwrap();
for i in 0..100 {
- ns.push_scope();
- ns.bind(Some("p".to_string()), format!("http://ns/{i}"));
+ ns.push_scope().unwrap();
+ ns.bind(Some("p".to_string()), format!("http://ns/{i}"))
+ .unwrap();
}
- assert_eq!(ns.resolve(Some("p")), Some("http://ns/99"));
+ assert_eq!(ns.resolve(Some("p")).unwrap(), Some("http://ns/99"));
for i in (0..100).rev() {
- ns.pop_scope();
+ ns.pop_scope().unwrap();
if i > 0 {
let expected = format!("http://ns/{}", i - 1);
- assert_eq!(ns.resolve(Some("p")), Some(expected.as_str()));
+ assert_eq!(ns.resolve(Some("p")).unwrap(), Some(expected.as_str()));
}
}
- assert_eq!(ns.resolve(Some("p")), None);
+ assert_eq!(ns.resolve(Some("p")).unwrap(), None);
}
// -- QName validation edge cases --
@@ -3265,34 +3672,46 @@
assert!(result.unwrap().contains("empty prefix or local part"));
}
- // -- split_owned_name edge cases --
+ // -- Borrowed QName ownership boundaries --
#[test]
- fn test_split_owned_name_with_prefix() {
- let (prefix, local) = split_owned_name("ns:elem".to_string());
- assert_eq!(prefix.as_deref(), Some("ns"));
+ fn split_name_with_prefix_preserves_source_borrows() {
+ let source = String::from("ns:elem");
+ let (prefix, local) = split_name(&source);
+ assert_eq!(prefix, Some("ns"));
assert_eq!(local, "elem");
+ assert_eq!(prefix.unwrap().as_ptr(), source.as_ptr());
+ assert_eq!(local.as_ptr(), source[3..].as_ptr());
}
#[test]
- fn test_split_owned_name_no_prefix() {
- let (prefix, local) = split_owned_name("elem".to_string());
+ fn split_name_without_prefix_preserves_source_borrow() {
+ let source = String::from("elem");
+ let (prefix, local) = split_name(&source);
assert_eq!(prefix, None);
assert_eq!(local, "elem");
+ assert_eq!(local.as_ptr(), source.as_ptr());
}
// -- pubid validation edge cases --
#[test]
fn test_validate_pubid_valid() {
- assert_eq!(validate_pubid("-//W3C//DTD XML 1.0//EN"), None);
+ ParserInput::new("")
+ .validate_pubid("-//W3C//DTD XML 1.0//EN")
+ .unwrap();
}
#[test]
fn test_validate_pubid_invalid_char() {
- let result = validate_pubid("bad\x01char");
- assert!(result.is_some());
- assert!(result.unwrap().contains("invalid character"));
+ let result = ParserInput::new("").validate_pubid("bad\x01char");
+ assert!(result.is_err());
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("invalid character")
+ );
}
// -- XML declaration edge cases --
@@ -3302,10 +3721,12 @@
let mut input = ParserInput::new("<?xml version=\"2.0\"?>");
let result = parse_xml_decl(&mut input);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("invalid version number"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("invalid version number")
+ );
}
#[test]
@@ -3313,10 +3734,12 @@
let mut input = ParserInput::new("<?xml version=\"1.0\" encoding=\"123bad\"?>");
let result = parse_xml_decl(&mut input);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("invalid encoding name"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("invalid encoding name")
+ );
}
#[test]
@@ -3324,10 +3747,12 @@
let mut input = ParserInput::new("<?xml version=\"1.0\" standalone=\"maybe\"?>");
let result = parse_xml_decl(&mut input);
assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .message
- .contains("standalone must be 'yes' or 'no'"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("standalone must be 'yes' or 'no'")
+ );
}
#[test]
@@ -3342,10 +3767,10 @@
#[test]
fn test_save_restore_position() {
let mut input = ParserInput::new("abcdef");
- input.advance(3);
+ input.advance(3).unwrap();
assert_eq!(input.peek(), Some(b'd'));
let saved = input.save_position();
- input.advance(2);
+ input.advance(2).unwrap();
assert_eq!(input.peek(), Some(b'f'));
input.restore_position(saved);
assert_eq!(input.peek(), Some(b'd'));
@@ -3357,7 +3782,7 @@
#[test]
fn test_depth_limit_via_parse_str_with_options() {
- use crate::parser::{parse_str_with_options, ParseOptions};
+ use crate::parser_xmloxide::parser::{ParseOptions, parse_str_with_options};
// 3 levels with limit 3 should succeed
let xml = "<a><b><c/></b></a>";
let opts = ParseOptions::default().max_depth(3);
@@ -3367,7 +3792,7 @@
let xml = "<a><b><c><d/></c></b></a>";
let result = parse_str_with_options(xml, &opts);
assert!(result.is_err());
- assert!(result.unwrap_err().message.contains("depth"));
+ assert!(result.unwrap_err().syntax_message().contains("depth"));
}
// -- Builtin entity matching edge cases --
@@ -3403,12 +3828,24 @@
fn test_expand_entity_text_counts_against_limit() {
// Set up a ParserInput with entity_map containing nested references
let mut input = ParserInput::new("");
- input
- .entity_map
- .insert("a".to_string(), "hello".to_string());
- input
- .entity_map
- .insert("b".to_string(), "&a; &a;".to_string());
+ Names::insert(
+ &mut input.entity_map,
+ "a".to_string(),
+ EntityDecl {
+ name: String::new(),
+ kind: EntityKind::Internal("hello".to_string()),
+ },
+ )
+ .unwrap();
+ Names::insert(
+ &mut input.entity_map,
+ "b".to_string(),
+ EntityDecl {
+ name: String::new(),
+ kind: EntityKind::Internal("&a; &a;".to_string()),
+ },
+ )
+ .unwrap();
input.set_max_entity_expansions(2);
// Expanding "b" should expand &a; twice, hitting the limit
@@ -3465,6 +3902,54 @@
assert_eq!(result, "<![CDATA[& not expanded]]>");
}
+ #[test]
+ fn entity_replacement_cdata_keeps_unicode_octets() {
+ // A replacement containing another reference takes the expansion path;
+ // CDATA must remain literal UTF-8 rather than widening each input byte.
+ let mut input = ParserInput::new("");
+ assert_eq!(
+ input
+ .expand_entity_text("<![CDATA[\u{e9}\u{540d} &unknown;]]>&")
+ .unwrap(),
+ "<![CDATA[\u{e9}\u{540d} &unknown;]]>&"
+ );
+ }
+
+ #[test]
+ fn entity_replacement_chain_does_not_use_the_call_stack() {
+ // DTD cycle detection already accepts long acyclic graphs. Expansion
+ // must not turn that accepted graph into attacker-controlled recursion.
+ let mut input = ParserInput::new("");
+ for index in 0..8192 {
+ let value = if index == 8191 {
+ "done".to_owned()
+ } else {
+ format!("&e{};", index + 1)
+ };
+ Names::insert(
+ &mut input.entity_map,
+ format!("e{index}"),
+ EntityDecl {
+ name: String::new(),
+ kind: EntityKind::Internal(value),
+ },
+ )
+ .unwrap();
+ }
+ assert_eq!(input.expand_entity_text("&e0;").unwrap(), "done");
+ assert_eq!(input.entity_expansions, 8192);
+ input.entity_expansions = 0;
+ input.set_max_entity_expansions(32);
+ assert!(
+ input
+ .expand_entity_text("&e0;")
+ .unwrap_err()
+ .syntax_message()
+ .contains("entity expansion limit")
+ );
+ assert_eq!(input.entity_expansions, 33);
+ }
+
// -- find_invalid_xml_char and may_contain_invalid_xml_chars --
#[test]
@@ -3,19 +3,55 @@
//! Implements a hand-rolled recursive descent parser for XML 1.0 (Fifth Edition).
//! See <https://www.w3.org/TR/xml/> for the specification.
-use std::collections::HashMap;
+use crate::xml::dom::storage::names::{NameStorage, Names};
+use crate::xml::dom::storage::{Native, Storage, Values};
-use crate::error::{ErrorSeverity, ParseError};
-use crate::tree::{Attribute, Document, NodeId, NodeKind};
-use crate::validation::dtd::{parse_dtd, serialize_dtd, AttributeDecl, AttributeType, EntityKind};
+use crate::parser_xmloxide::error::{ErrorSeverity, ParseDiagnostic};
+use crate::parser_xmloxide::tree::{Attribute, Document, NodeId, NodeKind};
+use crate::parser_xmloxide::validation::dtd::{
+ AttributeDecl, AttributeType, EntityKind, parse_dtd_in, serialize_dtd_in,
+};
+use super::ParseOptions;
use super::input::{
- find_invalid_xml_char, may_contain_invalid_xml_chars, parse_cdata_content,
- parse_comment_content, parse_pi_content, parse_xml_decl, split_name, split_owned_name,
- validate_pubid, ExternalEntityInfo, NamespaceResolver, ParserInput, XMLNS_NAMESPACE,
- XML_NAMESPACE,
+ NamespaceResolver, ParserInput, XML_NAMESPACE, XMLNS_NAMESPACE, find_invalid_xml_char,
+ may_contain_invalid_xml_chars, parse_cdata_content, parse_comment_content, parse_pi_content,
+ parse_xml_decl, split_name,
};
-use super::ParseOptions;
+
+type ParseError<S = Native> = crate::parser_xmloxide::error::ParseError<
+ <S as Storage>::FrozenText,
+ <S as Storage>::Values<ParseDiagnostic<<S as Storage>::FrozenText>>,
+>;
+
+struct QualifiedName<'a> {
+ prefix: Option<&'a str>,
+ local: &'a str,
+}
+
+impl std::fmt::Display for QualifiedName<'_> {
+ fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if let Some(prefix) = self.prefix {
+ write!(output, "{prefix}:")?;
+ }
+ output.write_str(self.local)
+ }
+}
+
+fn admit_attribute_comparison<S: Storage>(
+ storage: S,
+ left: &Attribute<S>,
+ right: &Attribute<S>,
+) -> Result<(), crate::xml::dom::ParseError> {
+ storage.charge(left.name.len() + right.name.len())?;
+ storage.charge(
+ left.prefix.as_deref().map_or(0, str::len) + right.prefix.as_deref().map_or(0, str::len),
+ )?;
+ storage.charge(
+ left.namespace.as_deref().map_or(0, str::len)
+ + right.namespace.as_deref().map_or(0, str::len),
+ )
+}
/// Default maximum amplification factor for entity/attribute expansion.
///
@@ -33,21 +69,22 @@
const MAX_ENTITY_DEPTH: u32 = 32;
/// The core XML parser.
-pub(crate) struct XmlParser<'a> {
+pub(crate) struct XmlParser<'a, S: NameStorage = Native> {
/// Shared low-level input state (position, peek, advance, name parsing, etc.).
- input: ParserInput<'a>,
+ input: ParserInput<'a, S>,
/// The document being built.
- doc: Document,
+ doc: Document<S>,
/// Parser options.
options: ParseOptions,
/// Namespace resolver managing the scope stack.
- ns: NamespaceResolver,
- /// DTD attribute type declarations, keyed by `(element_name, attr_name)`.
- /// Used for attribute value normalization of namespace URIs.
- attr_types: HashMap<(String, String), AttributeType>,
+ ns: NamespaceResolver<S>,
+ /// Tokenized attribute names indexed by their element name. The complete
+ /// declarations remain in attr_defaults; reads borrow both lookup keys.
+ attr_types: S::Names<S::Text, S::Names<S::Text, ()>>,
/// DTD attribute default value declarations, keyed by element name.
/// Used for applying default/fixed attributes from ATTLIST.
- attr_defaults: HashMap<String, Vec<AttributeDecl>>,
+ attr_defaults: S::Names<S::Text, S::Values<AttributeDecl<S>>>,
+ storage: S,
/// Total input size in bytes, used for amplification factor checking.
input_size: usize,
/// Running total of bytes added through default attribute expansion.
@@ -57,31 +94,45 @@
}
impl<'a> XmlParser<'a> {
- pub fn new(input: &'a str, options: &ParseOptions) -> Self {
- let mut pi = ParserInput::new(input);
+ pub fn new(input: &'a str, options: &ParseOptions) -> Result<Self, ParseError> {
+ let mut parser = Self::with_storage(input, options, Native)?;
+ parser
+ .input
+ .set_entity_resolver(options.entity_resolver.clone());
+ Ok(parser)
+ }
+}
+
+impl<'a, S: NameStorage> XmlParser<'a, S> {
+ pub(crate) fn with_storage(
+ input: &'a str,
+ options: &ParseOptions,
+ storage: S,
+ ) -> Result<Self, ParseError<S>> {
+ let mut pi = ParserInput::with_storage(input, storage)?;
pi.set_recover(options.recover);
pi.set_max_depth(options.max_depth);
pi.set_max_name_length(options.max_name_length);
pi.set_max_entity_expansions(options.max_entity_expansions);
- pi.set_entity_resolver(options.entity_resolver.clone());
// Pre-size the node arena — roughly 1 node per 30 bytes of input.
let estimated_nodes = (input.len() / 30).max(64);
- Self {
+ Ok(Self {
input: pi,
- doc: Document::with_capacity(estimated_nodes),
+ doc: Document::try_with_storage(estimated_nodes, storage)?,
options: options.clone(),
- ns: NamespaceResolver::new(),
- attr_types: HashMap::new(),
- attr_defaults: HashMap::new(),
+ ns: NamespaceResolver::with_storage(storage)?,
+ attr_types: storage.names(),
+ attr_defaults: storage.names(),
+ storage,
input_size: input.len(),
expansion_size: 0,
entity_depth: 0,
- }
+ })
}
/// Main parse entry point. Parses the entire document.
- pub fn parse(&mut self) -> Result<Document, ParseError> {
+ pub fn parse(mut self) -> Result<Document<S>, ParseError<S>> {
// Parse optional XML declaration — must be at the very start of the
// document with no leading whitespace (XML 1.0 §2.8).
if self.input.looking_at(b"<?xml ")
@@ -91,12 +142,12 @@
self.parse_xml_declaration()?;
// Skip whitespace immediately after the XML declaration — the
// serializer always emits its own newline after the declaration.
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
} else if !self.input.at_end() {
// If there's no XML declaration, skip any leading whitespace.
// Leading whitespace before a non-declaration is tolerated
// (it will be handled as misc content).
- let had_leading_ws = self.input.skip_whitespace();
+ let had_leading_ws = self.input.skip_whitespace()?;
// But if the whitespace was hiding an XML declaration, that's an error
if had_leading_ws
&& (self.input.looking_at(b"<?xml ")
@@ -128,7 +179,7 @@
self.parse_element(self.doc.root())?;
} else if self.options.recover {
self.input
- .push_diagnostic(ErrorSeverity::Error, "missing root element".to_string());
+ .push_diagnostic(ErrorSeverity::Error, "missing root element")?;
} else {
return Err(self.input.fatal("missing root element"));
}
@@ -136,40 +187,42 @@
// Parse trailing content (comments, PIs after root element)
self.parse_misc(self.doc.root())?;
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
if !self.input.at_end() && !self.options.recover {
return Err(self.input.fatal("content after document element"));
}
// Sync diagnostics from input to document before returning.
- self.doc.diagnostics = std::mem::take(&mut self.input.diagnostics);
+ self.doc.diagnostics = self.input.diagnostics;
- Ok(std::mem::take(&mut self.doc))
+ // Parsing is complete and the parser is consumed. Moving the arena
+ // avoids Document::default allocating a replacement tree on return.
+ Ok(self.doc)
}
// --- XML Declaration ---
// See XML 1.0 §2.8: [23] XMLDecl
- fn parse_xml_declaration(&mut self) -> Result<(), ParseError> {
+ fn parse_xml_declaration(&mut self) -> Result<(), ParseError<S>> {
let decl = parse_xml_decl(&mut self.input)?;
- self.doc.version = Some(decl.version);
- self.doc.encoding = decl.encoding;
+ self.doc.version = Some(self.storage.text(decl.version)?);
+ self.doc.encoding = self.storage.optional_text(decl.encoding)?;
self.doc.standalone = decl.standalone;
Ok(())
}
// --- Misc (comments, PIs, whitespace) ---
- fn parse_misc(&mut self, parent: NodeId) -> Result<(), ParseError> {
+ fn parse_misc(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
loop {
// Preserve document-level whitespace as text nodes (matches libxml2).
// libxml2 normalizes prolog/epilog whitespace to a single `\n`
// regardless of how many blank lines appear in the source.
- let ws = self.input.consume_whitespace();
+ let ws = self.input.consume_whitespace()?;
if !ws.is_empty() {
- let ws_node = self.doc.create_node(NodeKind::Text {
- content: "\n".to_string(),
- });
+ let ws_node = self.doc.try_create_node(NodeKind::Text {
+ content: self.storage.text("\n")?,
+ })?;
self.doc.append_child(parent, ws_node);
}
if self.input.at_end() {
@@ -190,7 +243,7 @@
// See XML 1.0 §2.8: [28] doctypedecl
#[allow(clippy::too_many_lines)]
- fn parse_doctype(&mut self, parent: NodeId) -> Result<(), ParseError> {
+ fn parse_doctype(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
// Consume <!DOCTYPE (case-insensitive match already checked by caller)
self.input.expect_str(b"<!DOCTYPE")?;
self.input.skip_whitespace_required()?;
@@ -198,7 +251,7 @@
// Read the root element name
let name = self.input.parse_name()?;
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
// Check for external ID: SYSTEM or PUBLIC
let mut system_id = None;
@@ -208,23 +261,17 @@
self.input.expect_str(b"SYSTEM")?;
self.input.skip_whitespace_required()?;
system_id = Some(self.input.parse_quoted_value()?);
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
} else if self.input.looking_at(b"PUBLIC") {
self.input.expect_str(b"PUBLIC")?;
self.input.skip_whitespace_required()?;
let pid = self.input.parse_quoted_value()?;
// Validate public ID characters per XML 1.0 §2.3 [13].
- if let Some(msg) = validate_pubid(&pid) {
- if self.options.recover {
- self.input.push_diagnostic(ErrorSeverity::Warning, msg);
- } else {
- return Err(self.input.fatal(msg));
- }
- }
+ self.input.validate_pubid(pid)?;
public_id = Some(pid);
self.input.skip_whitespace_required()?;
system_id = Some(self.input.parse_quoted_value()?);
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
}
// Flag whether there's an external DTD subset. Per XML 1.0 §4.1 WFC:
@@ -237,7 +284,7 @@
// Parse optional internal subset: [ ... ]
let mut internal_subset = None;
if self.input.peek() == Some(b'[') {
- self.input.advance(1);
+ self.input.advance(1)?;
let start = self.input.pos();
// Scan to matching ']', tracking depth for bracket chars inside
@@ -247,30 +294,30 @@
while !self.input.at_end() && depth > 0 {
if self.input.looking_at(b"<!--") {
// Skip XML comments (may contain apostrophes/quotes)
- self.input.advance(4);
+ self.input.advance(4)?;
while !self.input.at_end() && !self.input.looking_at(b"-->") {
- self.input.advance(1);
+ self.input.advance(1)?;
}
if !self.input.at_end() {
- self.input.advance(3); // consume -->
+ self.input.advance(3)?; // consume -->
}
} else if let Some(b'"' | b'\'') = self.input.peek() {
let quote = self.input.peek().unwrap_or(b'"');
- self.input.advance(1);
+ self.input.advance(1)?;
while !self.input.at_end() && self.input.peek() != Some(quote) {
- self.input.advance(1);
+ self.input.advance(1)?;
}
if !self.input.at_end() {
- self.input.advance(1); // closing quote
+ self.input.advance(1)?; // closing quote
}
} else if self.input.peek() == Some(b'[') {
depth += 1;
- self.input.advance(1);
+ self.input.advance(1)?;
} else if self.input.peek() == Some(b']') {
depth -= 1;
- self.input.advance(1);
+ self.input.advance(1)?;
} else {
- self.input.advance(1);
+ self.input.advance(1)?;
}
}
if depth > 0 {
@@ -282,97 +329,82 @@
// Extract the internal subset text (between '[' and ']').
let end = self.input.pos() - 1; // exclude the closing ']'
let subset_text = std::str::from_utf8(self.input.slice(start, end))
- .ok()
- .map(str::to_string);
+ .map_err(|_| self.input.fatal("invalid UTF-8 in internal subset"))?;
- if let Some(subset_text) = subset_text {
+ {
// Detect parameter entity references in the internal subset.
// Per XML 1.0 §4.1 WFC: Entity Declared, their presence
// means undeclared general entities are not WF errors.
+ self.storage.charge(subset_text.len())?;
if subset_text.contains('%') {
self.input.has_pe_references = true;
}
- match parse_dtd(&subset_text) {
+ match parse_dtd_in(subset_text, self.storage) {
Ok(dtd) => {
- // Wire entity declarations into the parser input
- // for entity reference resolution.
- for (ent_name, ent_decl) in &dtd.entities {
- match &ent_decl.kind {
- EntityKind::Internal(value) => {
- self.input
- .entity_map
- .insert(ent_name.clone(), value.clone());
- }
- EntityKind::External {
- system_id,
- public_id,
- } => {
- self.input.entity_external.insert(
- ent_name.clone(),
- ExternalEntityInfo {
- system_id: system_id.clone(),
- public_id: public_id.clone(),
- },
- );
- }
- }
+ // Serialize while the ordered declarations are intact,
+ // then move runtime tables instead of cloning payloads.
+ let serialized = serialize_dtd_in(&dtd, self.storage)?;
+ if !serialized.is_empty() {
+ internal_subset = Some(serialized);
}
+ // Runtime resolution uses the declaration table's
+ // exact owning layout: no second table or rehash.
+ self.input.entity_map = dtd.entities;
// Wire attribute type declarations for namespace
// URI normalization (XML 1.0 §3.3.3).
- for (element_name, attrs) in &dtd.attributes {
- for attr_decl in attrs {
- self.attr_types.insert(
- (element_name.clone(), attr_decl.attribute_name.clone()),
- attr_decl.attribute_type.clone(),
- );
+ for (element_name, attrs) in dtd.attributes.entries(self.storage)? {
+ let mut tokenized = self.storage.names();
+ for attr_decl in &**attrs {
+ self.storage.charge(1)?;
+ if !matches!(attr_decl.attribute_type, AttributeType::CData) {
+ tokenized.insert(
+ self.storage.text(&attr_decl.attribute_name)?,
+ (),
+ )?;
+ }
}
- }
-
- // Store attribute default values for later application.
- for (element_name, attrs) in &dtd.attributes {
- for attr_decl in attrs {
- self.attr_defaults
- .entry(element_name.clone())
- .or_default()
- .push(attr_decl.clone());
+ if tokenized.len() != 0 {
+ self.attr_types
+ .insert(self.storage.text(element_name.as_ref())?, tokenized)?;
}
}
- // Re-serialize the DTD from parsed structures for
- // consistent formatting (matches libxml2 behavior).
- let serialized = serialize_dtd(&dtd);
- if !serialized.is_empty() {
- internal_subset = Some(serialized);
- }
+ // There is one document type declaration. These are
+ // already grouped, ordered declaration lists.
+ self.attr_defaults = dtd.attributes;
}
Err(e) => {
+ if matches!(e, ParseError::<S>::Resource(_)) {
+ return Err(e);
+ }
+ let message = e.message().expect("resource refusal returned above");
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Warning,
- format!("error parsing DTD internal subset: {}", e.message),
- );
+ format_args!("error parsing DTD internal subset: {message}"),
+ )?;
} else {
return Err(self
.input
- .fatal(format!("error in DTD internal subset: {}", e.message)));
+ .fatal(format_args!("error in DTD internal subset: {message}")));
}
}
}
}
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
}
self.input.expect_byte(b'>')?;
- let doctype_id = self.doc.create_node(NodeKind::DocumentType {
- name,
- system_id,
- public_id,
+ let doctype_id = self.doc.try_create_node(NodeKind::DocumentType {
+ name: self.storage.text(name)?,
+ system_id: self.storage.optional_text(system_id)?,
+ public_id: self.storage.optional_text(public_id)?,
internal_subset,
- });
+ })?;
self.doc.append_child(parent, doctype_id);
Ok(())
}
@@ -381,15 +413,51 @@
// See XML 1.0 §3.1: [40] STag, [42] ETag, [44] EmptyElemTag
#[allow(clippy::too_many_lines)]
- fn parse_element(&mut self, parent: NodeId) -> Result<NodeId, ParseError> {
+ fn parse_element(&mut self, parent: NodeId) -> Result<NodeId, ParseError<S>> {
+ let (root, open) = self.parse_element_start(parent)?;
+ let mut pending = self.storage.values(0)?;
+ if let Some(scope) = open {
+ pending.push((root, scope))?;
+ }
+ while let Some(&(element, scope)) = pending.last() {
+ self.storage.charge(1)?;
+ if self.input.looking_at(b"</") {
+ self.parse_element_end(element, scope)?;
+ pending.pop();
+ } else if self.input.at_end() {
+ return Err(self
+ .input
+ .fatal("unexpected end of input in element content"));
+ } else if self.input.looking_at(b"<![CDATA[") {
+ self.parse_cdata(element)?;
+ } else if self.input.looking_at(b"<!--") {
+ self.parse_comment(element)?;
+ } else if self.input.looking_at(b"<?") {
+ self.parse_processing_instruction(element)?;
+ } else if self.input.peek() == Some(b'<') {
+ let (child, open) = self.parse_element_start(element)?;
+ if let Some(scope) = open {
+ pending.push((child, scope))?;
+ }
+ } else {
+ self.parse_char_data(element)?;
+ }
+ }
+ Ok(root)
+ }
+
+ fn parse_element_start(
+ &mut self,
+ parent: NodeId,
+ ) -> Result<(NodeId, Option<bool>), ParseError<S>> {
self.input.increment_depth()?;
self.input.expect_byte(b'<')?;
let name = self.input.parse_name()?;
- let mut attributes = Vec::new();
+ let mut attributes = self.storage.values(0)?;
// Parse attributes
loop {
- let had_ws = self.input.skip_whitespace();
+ let had_ws = self.input.skip_whitespace()?;
if self.input.peek() == Some(b'>') || self.input.looking_at(b"/>") {
break;
}
@@ -397,7 +465,7 @@
return Err(self.input.fatal("whitespace required between attributes"));
}
let attr = self.parse_attribute()?;
- attributes.push(attr);
+ attributes.push(attr)?;
}
// Check for duplicate attributes (XML 1.0 §3.1 WFC: Unique Att Spec)
@@ -407,24 +475,24 @@
let mut found_dup = false;
'outer: for i in 1..attributes.len() {
for j in 0..i {
- if attributes[i].name == attributes[j].name
- && attributes[i].prefix == attributes[j].prefix
+ admit_attribute_comparison(self.storage, &attributes[i], &attributes[j])?;
+ if attributes[i].name.as_ref() == attributes[j].name.as_ref()
+ && attributes[i].prefix.as_deref() == attributes[j].prefix.as_deref()
{
- let full_name = if let Some(ref pfx) = attributes[i].prefix {
- format!("{pfx}:{}", attributes[i].name)
- } else {
- attributes[i].name.clone()
+ let full_name = QualifiedName {
+ prefix: attributes[i].prefix.as_deref(),
+ local: &attributes[i].name,
};
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- format!("duplicate attribute: '{full_name}'"),
- );
+ format_args!("duplicate attribute: '{full_name}'"),
+ )?;
found_dup = true;
} else {
return Err(self
.input
- .fatal(format!("duplicate attribute: '{full_name}'")));
+ .fatal(format_args!("duplicate attribute: '{full_name}'")));
}
break 'outer;
}
@@ -443,53 +511,59 @@
// canonical form.
// Namespace declarations (xmlns, xmlns:prefix) are inserted before
// other attributes to match libxml2's attribute ordering.
- if let Some(defaults) = if self.attr_defaults.is_empty() {
+ if let Some(defaults) = if self.attr_defaults.len() == 0 {
None
} else {
- self.attr_defaults.get(&name).cloned()
+ self.attr_defaults.get(name, self.storage)?
} {
let mut insert_pos = 0; // insertion point for namespace declarations
- for attr_decl in &defaults {
- let (default_value, is_fixed) = match &attr_decl.default {
- crate::validation::dtd::AttributeDefault::Fixed(v) => (Some(v.clone()), true),
- crate::validation::dtd::AttributeDefault::Default(v) => {
- (Some(v.clone()), false)
+ for attr_decl in &**defaults {
+ self.storage.charge(1)?;
+ let default_value = match &attr_decl.default {
+ crate::parser_xmloxide::validation::dtd::AttributeDefault::Fixed(v)
+ | crate::parser_xmloxide::validation::dtd::AttributeDefault::Default(v) => {
+ Some(v.as_ref())
}
- _ => (None, false),
+ _ => None,
};
if let Some(value) = default_value {
// Check if the attribute is already present by comparing
// prefix:local components directly, avoiding format!/clone.
let attr_name = &attr_decl.attribute_name;
let (decl_pfx, decl_local) = split_name(attr_name);
- let already_present = attributes
- .iter()
- .any(|a| a.name == decl_local && a.prefix.as_deref() == decl_pfx);
+ let mut already_present = false;
+ for attribute in &*attributes {
+ self.storage
+ .charge(attribute.name.len() + decl_local.len())?;
+ if attribute.name.as_ref() == decl_local
+ && attribute.prefix.as_deref() == decl_pfx
+ {
+ already_present = true;
+ break;
+ }
+ }
if !already_present {
// Track expansion for amplification factor check
// (both #FIXED and #DEFAULT contribute to expansion).
self.expansion_size += attr_name.len() + value.len();
- // Insert both #FIXED and #DEFAULT defaults into the
- // tree. The `is_fixed` flag is no longer used to gate
- // insertion; it would only matter if we additionally
- // validated source attributes against #FIXED values,
- // which is a separate validation step.
- let _ = is_fixed;
let (decl_prefix, decl_local) = split_name(attr_name);
let attr = Attribute {
- name: decl_local.to_string(),
- value,
- prefix: decl_prefix.map(String::from),
+ name: self.storage.text(decl_local)?,
+ value: self.storage.text(value)?,
+ prefix: self.storage.optional_text(decl_prefix)?,
namespace: None,
raw_value: None,
};
- let is_ns_decl = attr_name == "xmlns" || attr_name.starts_with("xmlns:");
+ let is_ns_decl =
+ attr_name.as_ref() == "xmlns" || attr_name.starts_with("xmlns:");
if is_ns_decl {
- attributes.insert(insert_pos, attr);
+ self.storage.charge(attributes.len() - insert_pos)?;
+ attributes.push(attr)?;
+ attributes[insert_pos..].rotate_right(1);
insert_pos += 1;
} else {
- attributes.push(attr);
+ attributes.push(attr)?;
}
}
}
@@ -511,22 +585,22 @@
// when not needed. Skip the scan entirely when there are no attributes.
let has_ns_decls = !attributes.is_empty()
&& attributes.iter().any(|a| {
- a.prefix.as_deref() == Some("xmlns") || (a.prefix.is_none() && a.name == "xmlns")
+ a.prefix.as_deref() == Some("xmlns")
+ || (a.prefix.is_none() && a.name.as_ref() == "xmlns")
});
if has_ns_decls {
- self.ns.push_scope();
+ self.ns.push_scope()?;
}
// Split into prefix and local name for namespace processing.
- let (prefix, local_name) = split_name(&name);
+ let (prefix, local_name) = split_name(name);
// Validate QName syntax: check for multiple colons (the local part
// should not contain a colon after split_name).
if prefix.is_some() && local_name.contains(':') {
let msg = "QName contains multiple colons";
if self.options.recover {
- self.input
- .push_diagnostic(ErrorSeverity::Error, msg.to_string());
+ self.input.push_diagnostic(ErrorSeverity::Error, msg)?;
} else {
return Err(self.input.fatal(msg));
}
@@ -537,8 +611,8 @@
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "elements must not have the prefix 'xmlns'".to_string(),
- );
+ "elements must not have the prefix 'xmlns'",
+ )?;
} else {
return Err(self
.input
@@ -549,7 +623,7 @@
// Scan attributes for namespace declarations and bind them,
// with validation of namespace constraints.
// In recovery mode, some invalid attributes may be stripped.
- let mut strip_attr_indices: Vec<usize> = Vec::new();
+ let mut strip_attr_indices = self.storage.values(0)?;
if has_ns_decls {
for (attr_idx, attr) in attributes.iter().enumerate() {
if attr.prefix.as_deref() == Some("xmlns") {
@@ -563,8 +637,7 @@
if declared_prefix.is_empty() {
let msg = "namespace prefix must not be empty (invalid QName 'xmlns:')";
if self.options.recover {
- self.input
- .push_diagnostic(ErrorSeverity::Error, msg.to_string());
+ self.input.push_diagnostic(ErrorSeverity::Error, msg)?;
continue;
}
return Err(self.input.fatal(msg));
@@ -572,8 +645,7 @@
if declared_prefix.contains(':') {
let msg = "QName contains multiple colons";
if self.options.recover {
- self.input
- .push_diagnostic(ErrorSeverity::Error, msg.to_string());
+ self.input.push_diagnostic(ErrorSeverity::Error, msg)?;
} else {
return Err(self.input.fatal(msg));
}
@@ -583,11 +655,13 @@
// type. For non-CDATA types (e.g., NMTOKEN), whitespace is
// collapsed per XML 1.0 §3.3.3. Construct the full attribute
// name only when attr_types is non-empty (DTD present).
- let ns_value = if self.attr_types.is_empty() {
- attr.value.clone()
+ let ns_value = if self.attr_types.len() == 0 {
+ self.storage.text(&attr.value)?
} else {
- let attr_qname = format!("xmlns:{declared_prefix}");
- self.normalize_attr_value_by_type(&name, &attr_qname, &attr.value)
+ let attr_qname = self
+ .storage
+ .formatted(format_args!("xmlns:{declared_prefix}"))?;
+ self.normalize_attr_value_by_type(name, &attr_qname, &attr.value)?
};
// XML 1.0 Namespaces: cannot unbind a prefix (xmlns:prefix="").
@@ -595,22 +669,22 @@
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- format!("namespace prefix '{declared_prefix}' cannot be undeclared in XML 1.0"),
- );
+ format_args!("namespace prefix '{declared_prefix}' cannot be undeclared in XML 1.0"),
+ )?;
} else {
- return Err(self.input.fatal(format!(
+ return Err(self.input.fatal(format_args!(
"namespace prefix '{declared_prefix}' cannot be undeclared in XML 1.0"
)));
}
}
// Cannot declare the 'xmlns' prefix itself.
- if declared_prefix == "xmlns" {
+ if declared_prefix.as_ref() == "xmlns" {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "the 'xmlns' prefix must not be declared".to_string(),
- );
+ "the 'xmlns' prefix must not be declared",
+ )?;
} else {
return Err(self
.input
@@ -619,15 +693,15 @@
}
// 'xml' prefix must map to the XML namespace URI and vice versa.
- if declared_prefix == "xml" && ns_value != XML_NAMESPACE {
+ if declared_prefix.as_ref() == "xml" && ns_value.as_ref() != XML_NAMESPACE {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "the 'xml' prefix must be bound to the XML namespace".to_string(),
- );
+ "the 'xml' prefix must be bound to the XML namespace",
+ )?;
// In recovery mode, strip the invalid rebinding
// (matches libxml2: output is <tst/> not <tst xmlns:xml="..."/>).
- strip_attr_indices.push(attr_idx);
+ strip_attr_indices.push(attr_idx)?;
continue;
}
return Err(self
@@ -636,13 +710,12 @@
}
// No other prefix may be bound to the XML namespace URI.
- if declared_prefix != "xml" && ns_value == XML_NAMESPACE {
+ if declared_prefix.as_ref() != "xml" && ns_value.as_ref() == XML_NAMESPACE {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "only the 'xml' prefix may be bound to the XML namespace"
- .to_string(),
- );
+ "only the 'xml' prefix may be bound to the XML namespace",
+ )?;
} else {
return Err(self
.input
@@ -651,12 +724,12 @@
}
// No prefix may be bound to the xmlns namespace URI.
- if ns_value == XMLNS_NAMESPACE {
+ if ns_value.as_ref() == XMLNS_NAMESPACE {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "the xmlns namespace must not be bound to any prefix".to_string(),
- );
+ "the xmlns namespace must not be bound to any prefix",
+ )?;
} else {
return Err(self
.input
@@ -664,51 +737,49 @@
}
}
- self.ns.bind(Some(attr.name.clone()), ns_value);
- } else if attr.prefix.is_none() && attr.name == "xmlns" {
+ self.ns
+ .bind(Some(self.storage.text(&attr.name)?), ns_value)?;
+ } else if attr.prefix.is_none() && attr.name.as_ref() == "xmlns" {
// Default namespace declaration: xmlns="uri"
// Normalize namespace URI based on DTD-declared attribute type.
- let ns_value = if self.attr_types.is_empty() {
- attr.value.clone()
+ let ns_value = if self.attr_types.len() == 0 {
+ self.storage.text(&attr.value)?
} else {
- self.normalize_attr_value_by_type(&name, "xmlns", &attr.value)
+ self.normalize_attr_value_by_type(name, "xmlns", &attr.value)?
};
// Cannot bind default namespace to the XML or xmlns namespace URIs.
- if ns_value == XML_NAMESPACE {
+ if ns_value.as_ref() == XML_NAMESPACE {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "the xml namespace must not be declared as the default namespace"
- .to_string(),
- );
+ "the xml namespace must not be declared as the default namespace",
+ )?;
} else {
return Err(self.input.fatal(
"the xml namespace must not be declared as the default namespace",
));
}
}
- if ns_value == XMLNS_NAMESPACE {
+ if ns_value.as_ref() == XMLNS_NAMESPACE {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "the xmlns namespace must not be declared as the default namespace"
- .to_string(),
- );
+ "the xmlns namespace must not be declared as the default namespace",
+ )?;
} else {
return Err(self.input.fatal(
"the xmlns namespace must not be declared as the default namespace",
));
}
}
- self.ns.bind(None, ns_value);
+ self.ns.bind(None, ns_value)?;
} else if attr.prefix.is_some() && attr.name.contains(':') {
// Validate QName syntax for prefixed non-namespace attributes.
let msg = "QName contains multiple colons";
if self.options.recover {
- self.input
- .push_diagnostic(ErrorSeverity::Error, msg.to_string());
+ self.input.push_diagnostic(ErrorSeverity::Error, msg)?;
} else {
return Err(self.input.fatal(msg));
}
@@ -717,12 +788,11 @@
} else {
// No namespace declarations — only validate QName syntax for
// prefixed attributes (checking for multiple colons).
- for attr in &attributes {
+ for attr in &*attributes {
if attr.prefix.is_some() && attr.name.contains(':') {
let msg = "QName contains multiple colons";
if self.options.recover {
- self.input
- .push_diagnostic(ErrorSeverity::Error, msg.to_string());
+ self.input.push_diagnostic(ErrorSeverity::Error, msg)?;
} else {
return Err(self.input.fatal(msg));
}
@@ -731,21 +801,22 @@
}
// Resolve the element's namespace URI from its prefix.
- let elem_ns = self.ns.resolve(prefix).map(String::from);
+ let elem_ns = self.storage.optional_text(self.ns.resolve(prefix)?)?;
// Check for unbound element prefix.
- if let Some(pfx) = prefix {
- if pfx != "xml" && elem_ns.is_none() {
- if self.options.recover {
- self.input.push_diagnostic(
- ErrorSeverity::Error,
- format!("unbound namespace prefix '{pfx}'"),
- );
- } else {
- return Err(self
- .input
- .fatal(format!("unbound namespace prefix '{pfx}'")));
- }
+ if let Some(pfx) = prefix
+ && pfx != "xml"
+ && elem_ns.is_none()
+ {
+ if self.options.recover {
+ self.input.push_diagnostic(
+ ErrorSeverity::Error,
+ format_args!("unbound namespace prefix '{pfx}'"),
+ )?;
+ } else {
+ return Err(self
+ .input
+ .fatal(format_args!("unbound namespace prefix '{pfx}'")));
}
}
@@ -757,22 +828,24 @@
.iter()
.any(|a| a.prefix.is_some() && a.prefix.as_deref() != Some("xmlns"));
if has_prefixed_attrs {
- for attr in &mut attributes {
+ for attr in &mut *attributes {
if let Some(pfx) = &attr.prefix {
- if pfx == "xmlns" {
+ if pfx.as_ref() == "xmlns" {
continue; // namespace declaration, not a real attribute prefix
}
- let resolved = self.ns.resolve(Some(pfx.as_str())).map(String::from);
- if pfx != "xml" && resolved.is_none() {
+ let resolved = self
+ .storage
+ .optional_text(self.ns.resolve(Some(pfx.as_ref()))?)?;
+ if pfx.as_ref() != "xml" && resolved.is_none() {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- format!("unbound namespace prefix '{pfx}' on attribute"),
- );
+ format_args!("unbound namespace prefix '{pfx}' on attribute"),
+ )?;
} else {
- return Err(self
- .input
- .fatal(format!("unbound namespace prefix '{pfx}' on attribute")));
+ return Err(self.input.fatal(format_args!(
+ "unbound namespace prefix '{pfx}' on attribute"
+ )));
}
}
attr.namespace = resolved;
@@ -795,25 +868,29 @@
continue;
}
for j in 0..i {
+ admit_attribute_comparison(self.storage, &attributes[i], &attributes[j])?;
if attributes[j].namespace.is_none() {
continue;
}
- if attributes[i].namespace == attributes[j].namespace
- && attributes[i].name == attributes[j].name
+ if attributes[i].namespace.as_deref() == attributes[j].namespace.as_deref()
+ && attributes[i].name.as_ref() == attributes[j].name.as_ref()
{
- let display = if let Some(ns) = &attributes[i].namespace {
- format!("{{{}}}:{}", ns, attributes[i].name)
- } else {
- attributes[i].name.clone()
- };
+ let ns = attributes[i]
+ .namespace
+ .as_deref()
+ .expect("namespace checked above");
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- format!("namespace-aware duplicate attribute: '{display}'"),
- );
+ format_args!(
+ "namespace-aware duplicate attribute: '{{{ns}}}:{}'",
+ attributes[i].name
+ ),
+ )?;
} else {
- return Err(self.input.fatal(format!(
- "namespace-aware duplicate attribute: '{display}'"
+ return Err(self.input.fatal(format_args!(
+ "namespace-aware duplicate attribute: '{{{ns}}}:{}'",
+ attributes[i].name
)));
}
break 'ns_outer;
@@ -827,88 +904,86 @@
if !strip_attr_indices.is_empty() {
// Remove in reverse order to preserve indices.
for &idx in strip_attr_indices.iter().rev() {
- attributes.remove(idx);
+ self.storage.charge(attributes.len() - idx)?;
+ attributes[idx..].rotate_left(1);
+ attributes.pop();
}
}
- // Consume the original name String via split_owned_name, avoiding a
- // re-allocation for unprefixed names (the common case). For unprefixed
- // names, split_owned_name returns (None, name) — just a move, zero copy.
- let (elem_prefix_owned, elem_local_owned) = split_owned_name(name);
+ // The lexical QName remains borrowed through validation. Copy only
+ // the parts actually owned by the completed element node.
+ let (elem_prefix, elem_local) = split_name(name);
+ let elem_prefix_owned = self.storage.optional_text(elem_prefix)?;
+ let elem_local_owned = self.storage.text(elem_local)?;
// Auto-populate id_map for "id" attributes (enables element_by_id
// and fast CSS #id selectors without requiring DTD validation).
- let id_value = attributes.iter().find_map(|a| {
- if a.prefix.is_none() && a.name == "id" {
- Some(a.value.clone())
- } else {
- None
- }
- });
-
- let elem_id = self.doc.create_node(NodeKind::Element {
+ let elem_id = self.doc.try_create_node(NodeKind::Element {
name: elem_local_owned,
prefix: elem_prefix_owned,
namespace: elem_ns,
attributes,
- });
+ })?;
self.doc.append_child(parent, elem_id);
- if let Some(id_val) = id_value {
- self.doc.set_id(&id_val, elem_id);
- }
+ self.doc.try_register_element_id(elem_id)?;
// Empty element tag <foo/>
if self.input.looking_at(b"/>") {
- self.input.advance(2);
+ self.input.advance(2)?;
if has_ns_decls {
- self.ns.pop_scope();
+ self.ns.pop_scope()?;
}
self.input.decrement_depth();
- return Ok(elem_id);
+ return Ok((elem_id, None));
}
// Start tag close >
self.input.expect_byte(b'>')?;
- // Parse element content
- self.parse_content(elem_id)?;
+ Ok((elem_id, Some(has_ns_decls)))
+ }
- // Parse end tag — read back the stored name from the tree node
- // for matching, since the original name was consumed by split_owned_name.
+ fn parse_element_end(
+ &mut self,
+ elem_id: NodeId,
+ has_ns_decls: bool,
+ ) -> Result<(), ParseError<S>> {
+ // Match the end tag against the node's retained parts without
+ // formatting or allocating another qualified name.
self.input.expect_str(b"</")?;
let (match_prefix, match_local) = {
let node = self.doc.node(elem_id);
match &node.kind {
- NodeKind::Element { name, prefix, .. } => (prefix.as_deref(), name.as_str()),
+ NodeKind::Element { name, prefix, .. } => (prefix.as_deref(), name.as_ref()),
_ => unreachable!(),
}
};
if let Some(end_name) = self.input.parse_name_eq_parts(match_prefix, match_local)? {
- let expected = match match_prefix {
- Some(pfx) => format!("{pfx}:{match_local}"),
- None => match_local.to_string(),
+ let expected = QualifiedName {
+ prefix: match_prefix,
+ local: match_local,
};
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- format!("mismatched end tag: expected </{expected}>, found </{end_name}>"),
- );
+ format_args!("mismatched end tag: expected </{expected}>, found </{end_name}>"),
+ )?;
} else {
- return Err(self.input.fatal(format!(
+ return Err(self.input.fatal(format_args!(
"mismatched end tag: expected </{expected}>, found </{end_name}>"
)));
}
}
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
self.input.expect_byte(b'>')?;
// Pop the namespace scope when leaving this element (only if we pushed).
if has_ns_decls {
- self.ns.pop_scope();
+ self.ns.pop_scope()?;
}
self.input.decrement_depth();
- Ok(elem_id)
+ Ok(())
}
/// Normalizes an attribute value based on its DTD-declared type.
@@ -921,65 +996,31 @@
element_name: &str,
attr_name: &str,
value: &str,
- ) -> String {
- // Fast path: skip lookup when no DTD attribute types are declared
- // (the common case for documents without a DTD).
- if !self.attr_types.is_empty() {
- let key = (element_name.to_string(), attr_name.to_string());
- if let Some(attr_type) = self.attr_types.get(&key) {
- if !matches!(attr_type, AttributeType::CData) {
- return value.split_whitespace().collect::<Vec<_>>().join(" ");
- }
- }
- }
- value.to_string()
- }
-
- // --- Content ---
- // See XML 1.0 §3.1: [43] content
-
- fn parse_content(&mut self, parent: NodeId) -> Result<(), ParseError> {
- loop {
- if self.input.at_end() {
- if self.options.recover {
- break;
- }
- return Err(self
- .input
- .fatal("unexpected end of input in element content"));
- }
-
- // End tag starts
- if self.input.looking_at(b"</") {
- break;
- }
-
- if self.input.looking_at(b"<![CDATA[") {
- self.parse_cdata(parent)?;
- } else if self.input.looking_at(b"<!--") {
- self.parse_comment(parent)?;
- } else if self.input.looking_at(b"<?") {
- self.parse_processing_instruction(parent)?;
- } else if self.input.peek() == Some(b'<') {
- self.parse_element(parent)?;
- } else {
- self.parse_char_data(parent)?;
- }
+ ) -> Result<S::Text, ParseError<S>> {
+ let mut normalized = self.storage.text(value)?;
+ if let Some(attributes) = self.attr_types.get(element_name, self.storage)?
+ && attributes.get(attr_name, self.storage)?.is_some()
+ {
+ // XML 1.0 Fifth Edition §3.3.3: after character-reference and
+ // literal-whitespace processing, only space (#x20) is collapsed.
+ // Unicode whitespace and referenced tabs are not spaces.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#AVNormalize
+ self.storage.normalize_id(&mut normalized)?;
}
- Ok(())
+ Ok(normalized)
}
// --- Character Data ---
// See XML 1.0 §2.4: [14] CharData
#[allow(clippy::too_many_lines)]
- fn parse_char_data(&mut self, parent: NodeId) -> Result<(), ParseError> {
- let mut text = String::new();
+ fn parse_char_data(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
+ let mut text = self.storage.text("")?;
while !self.input.at_end() {
// Bulk scan: find the next `<`, `&`, or `]]>` boundary and
// consume all safe bytes in one go.
- let safe_len = self.input.scan_char_data();
+ let safe_len = self.input.scan_char_data()?;
if safe_len > 0 {
let start = self.input.pos();
let chunk = std::str::from_utf8(self.input.slice(start, start + safe_len))
@@ -987,39 +1028,27 @@
// Fast byte-level pre-check for invalid XML chars (0x7F,
// U+FFFE, U+FFFF). Skips the expensive char-by-char
// validation for the 99.9% of chunks that are clean.
+ self.storage.charge(chunk.len())?;
let bad_char = if may_contain_invalid_xml_chars(chunk.as_bytes()) {
+ self.storage.charge(chunk.len())?;
find_invalid_xml_char(chunk)
} else {
None
};
// Append text with CR normalization if needed (XML 1.0 §2.11)
- if chunk.as_bytes().contains(&b'\r') {
- let mut chars = chunk.chars().peekable();
- while let Some(ch) = chars.next() {
- if ch == '\r' {
- if chars.peek() == Some(&'\n') {
- chars.next();
- }
- text.push('\n');
- } else {
- text.push(ch);
- }
- }
- } else {
- text.push_str(chunk);
- }
+ super::input::append_normalized_line_ends(self.storage, &mut text, chunk)?;
// chunk borrow released — safe to mutably borrow self.input
- self.input.advance_counting_lines(safe_len);
+ self.input.advance_counting_lines(safe_len)?;
if let Some(bad) = bad_char {
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- format!("invalid XML character: U+{:04X}", bad as u32),
- );
+ format_args!("invalid XML character: U+{:04X}", bad as u32),
+ )?;
} else {
return Err(self
.input
- .fatal(format!("invalid XML character: U+{:04X}", bad as u32)));
+ .fatal(format_args!("invalid XML character: U+{:04X}", bad as u32)));
}
}
continue;
@@ -1034,10 +1063,10 @@
if self.options.recover {
self.input.push_diagnostic(
ErrorSeverity::Error,
- "']]>' not allowed in character data".to_string(),
- );
- text.push_str("]]>");
- self.input.advance(3);
+ "']]>' not allowed in character data",
+ )?;
+ self.storage.append(&mut text, "]]>")?;
+ self.input.advance(3)?;
continue;
}
return Err(self.input.fatal("']]>' not allowed in character data"));
@@ -1049,15 +1078,13 @@
// node is created and the entity's replacement text is parsed
// as content, attached as children of the EntityRef node.
// Skip the peek for builtins at byte level to avoid String allocation.
- if self.input.peek_at(1) != Some(b'#')
- && !self.is_looking_at_builtin_entity_ref()
- && self.peek_entity_ref_name().is_some()
- {
+ if self.input.peek_at(1) != Some(b'#') && !self.is_looking_at_builtin_entity_ref() {
// Flush accumulated text before the entity ref
if !text.is_empty() {
- let text_id = self.doc.create_node(NodeKind::Text {
- content: std::mem::take(&mut text),
- });
+ let empty = self.storage.text("")?;
+ let text_id = self.doc.try_create_node(NodeKind::Text {
+ content: std::mem::replace(&mut text, empty),
+ })?;
self.doc.append_child(parent, text_id);
}
self.parse_entity_ref_in_content(parent)?;
@@ -1066,16 +1093,20 @@
self.input.parse_reference_into(&mut text)?;
} else {
let ch = self.input.next_char()?;
- text.push(ch);
+ self.storage
+ .append(&mut text, ch.encode_utf8(&mut [0; 4]))?;
}
}
if !text.is_empty() {
// Strip blank text nodes if configured
- if self.options.no_blanks && text.chars().all(char::is_whitespace) {
- return Ok(());
+ if self.options.no_blanks {
+ self.storage.charge(text.len())?;
+ if text.chars().all(char::is_whitespace) {
+ return Ok(());
+ }
}
- let text_id = self.doc.create_node(NodeKind::Text { content: text });
+ let text_id = self.doc.try_create_node(NodeKind::Text { content: text })?;
self.doc.append_child(parent, text_id);
}
@@ -1089,9 +1120,9 @@
/// replacement text (XML 1.0 §4.5: character references expanded at
/// declaration time) as content, attaching the resulting nodes as
/// children of the `EntityRef` node.
- fn parse_entity_ref_in_content(&mut self, parent: NodeId) -> Result<(), ParseError> {
+ fn parse_entity_ref_in_content(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
// Consume `&name;`
- self.input.advance(1); // '&'
+ self.input.advance(1)?; // '&'
let name = self.input.parse_name()?;
self.input.expect_byte(b';')?;
@@ -1099,50 +1130,70 @@
self.input.count_entity_expansion()?;
// Resolve the replacement text (XML 1.0 §4.5).
- let replacement: Option<String> = if let Some(raw) = self.input.entity_map.get(&name) {
- Some(crate::validation::dtd::expand_char_refs_only(raw))
- } else if let Some(info) = self.input.entity_external.get(&name).cloned() {
- if let Some(resolver) = self.options.entity_resolver.clone() {
- let request = crate::parser::ExternalEntityRequest {
- name: &name,
- system_id: &info.system_id,
- public_id: info.public_id.as_deref(),
- };
- if let Some(resolved) = resolver(request) {
- // External parsed entities may begin with a text
- // declaration (XML 1.0 §4.3.1) which is not content.
- Some(strip_text_declaration(&resolved).to_string())
+ let declaration = self.input.entity_map.get(name, self.storage)?;
+ let replacement: Option<S::Text> =
+ if let Some(crate::parser_xmloxide::validation::dtd::EntityDecl {
+ kind: EntityKind::Internal(raw),
+ ..
+ }) = declaration
+ {
+ Some(
+ crate::parser_xmloxide::validation::dtd::expand_char_refs_only(
+ raw,
+ self.storage,
+ )?,
+ )
+ } else if let Some(crate::parser_xmloxide::validation::dtd::EntityDecl {
+ kind:
+ EntityKind::External {
+ system_id,
+ public_id,
+ },
+ ..
+ }) = declaration
+ {
+ if let Some(resolver) = self.input.external_entity_resolver() {
+ let request = crate::parser_xmloxide::parser::ExternalEntityRequest {
+ name,
+ system_id,
+ public_id: public_id.as_deref(),
+ };
+ if let Some(resolved) = resolver(request) {
+ // External parsed entities may begin with a text
+ // declaration (XML 1.0 §4.3.1) which is not content.
+ self.storage.charge(resolved.len())?;
+ Some(self.storage.text(strip_text_declaration(&resolved))?)
+ } else {
+ return Err(self.input.fatal(format_args!(
+ "reference to external entity '{name}' is not supported"
+ )));
+ }
} else {
- return Err(self.input.fatal(format!(
+ return Err(self.input.fatal(format_args!(
"reference to external entity '{name}' is not supported"
)));
}
+ } else if self.input.has_pe_references || self.input.has_external_dtd {
+ // Undeclared entity in tolerant mode (external DTD or PE refs
+ // present, XML 1.0 §4.1 WFC: Entity Declared): preserve the
+ // reference without a value.
+ None
+ } else if self.options.recover {
+ self.input.push_diagnostic(
+ ErrorSeverity::Warning,
+ format_args!("unknown entity reference: &{name};"),
+ )?;
+ None
} else {
- return Err(self.input.fatal(format!(
- "reference to external entity '{name}' is not supported"
- )));
- }
- } else if self.input.has_pe_references || self.input.has_external_dtd {
- // Undeclared entity in tolerant mode (external DTD or PE refs
- // present, XML 1.0 §4.1 WFC: Entity Declared): preserve the
- // reference without a value.
- None
- } else if self.options.recover {
- self.input.push_diagnostic(
- ErrorSeverity::Warning,
- format!("unknown entity reference: &{name};"),
- );
- None
- } else {
- return Err(self
- .input
- .fatal(format!("unknown entity reference: &{name};")));
- };
+ return Err(self
+ .input
+ .fatal(format_args!("unknown entity reference: &{name};")));
+ };
- let ref_id = self.doc.create_node(NodeKind::EntityRef {
- name: name.clone(),
- value: replacement.clone(),
- });
+ let ref_id = self.doc.try_create_node(NodeKind::EntityRef {
+ name: self.storage.text(name)?,
+ value: self.storage.optional_text(replacement.as_deref())?,
+ })?;
self.doc.append_child(parent, ref_id);
let Some(replacement) = replacement else {
@@ -1160,17 +1211,15 @@
.fatal("maximum entity amplification factor exceeded"));
}
- if !replacement.contains('<') && !replacement.contains('&') {
+ self.storage.charge(replacement.len())?;
+ if !replacement.bytes().any(|byte| byte == b'<' || byte == b'&') {
// Fast path: plain character data — no markup, no references.
- let content = if replacement.contains('\r') {
- normalize_line_ends(&replacement)
- } else {
- replacement
- };
- let text_id = self.doc.create_node(NodeKind::Text { content });
+ let mut content = self.storage.text("")?;
+ super::input::append_normalized_line_ends(self.storage, &mut content, &replacement)?;
+ let text_id = self.doc.try_create_node(NodeKind::Text { content })?;
self.doc.append_child(ref_id, text_id);
} else {
- self.parse_replacement_as_content(&name, &replacement, ref_id)?;
+ self.parse_replacement_as_content(name, &replacement, ref_id)?;
}
Ok(())
}
@@ -1187,14 +1236,15 @@
entity_name: &str,
replacement: &str,
ref_id: NodeId,
- ) -> Result<(), ParseError> {
+ ) -> Result<(), ParseError<S>> {
if self.entity_depth >= MAX_ENTITY_DEPTH {
- return Err(self.input.fatal(format!(
+ return Err(self.input.fatal(format_args!(
"entity '{entity_name}' exceeds maximum entity nesting depth"
)));
}
- let mut sub = XmlParser::new(replacement, &self.options);
+ let mut sub = XmlParser::with_storage(replacement, &self.options, self.storage)?;
+ sub.input.inherit_entity_resolver(&self.input);
sub.entity_depth = self.entity_depth + 1;
sub.input_size = self.input_size;
sub.expansion_size = self.expansion_size;
@@ -1204,51 +1254,51 @@
// enough to overflow the stack).
sub.input.set_depth(self.input.depth());
// Move shared state into the sub-parser (returned below).
- sub.input.entity_map = std::mem::take(&mut self.input.entity_map);
- sub.input.entity_external = std::mem::take(&mut self.input.entity_external);
+ std::mem::swap(&mut sub.input.entity_map, &mut self.input.entity_map);
sub.input.has_pe_references = self.input.has_pe_references;
sub.input.has_external_dtd = self.input.has_external_dtd;
sub.input.entity_expansions = self.input.entity_expansions;
- sub.attr_types = std::mem::take(&mut self.attr_types);
- sub.attr_defaults = std::mem::take(&mut self.attr_defaults);
+ std::mem::swap(&mut sub.attr_types, &mut self.attr_types);
+ std::mem::swap(&mut sub.attr_defaults, &mut self.attr_defaults);
std::mem::swap(&mut sub.ns, &mut self.ns);
let sub_root = sub.doc.root();
let result = sub.parse_content_fragment(sub_root);
// Restore shared state regardless of outcome.
- self.input.entity_map = std::mem::take(&mut sub.input.entity_map);
- self.input.entity_external = std::mem::take(&mut sub.input.entity_external);
+ std::mem::swap(&mut self.input.entity_map, &mut sub.input.entity_map);
self.input.entity_expansions = sub.input.entity_expansions;
- self.attr_types = std::mem::take(&mut sub.attr_types);
- self.attr_defaults = std::mem::take(&mut sub.attr_defaults);
+ std::mem::swap(&mut self.attr_types, &mut sub.attr_types);
+ std::mem::swap(&mut self.attr_defaults, &mut sub.attr_defaults);
std::mem::swap(&mut self.ns, &mut sub.ns);
self.expansion_size = sub.expansion_size;
if !sub.input.diagnostics.is_empty() {
- self.input.diagnostics.append(&mut sub.input.diagnostics);
+ for diagnostic in sub.input.diagnostics.drain_all()? {
+ self.input.diagnostics.push(diagnostic)?;
+ }
}
if let Err(e) = result {
+ if matches!(e, ParseError::<S>::Resource(_)) {
+ return Err(e);
+ }
+ let message = e.message().expect("resource refusal returned above");
// Propagate security-limit errors and already-wrapped entity
// errors unchanged to avoid nested message wrapping.
- if e.message.contains("exceeded")
- || e.message.contains("replacement text is not well-formed")
+ if message.contains("exceeded")
+ || message.contains("replacement text is not well-formed")
{
return Err(e);
}
- return Err(self.input.fatal(format!(
+ return Err(self.input.fatal(format_args!(
"entity '{entity_name}' replacement text is not well-formed \
XML content: {}",
- e.message
+ message
)));
}
// Graft the parsed nodes into our arena under the EntityRef node.
- let sub_doc = sub.doc;
- let top_level: Vec<NodeId> = sub_doc.children(sub_doc.root()).collect();
- for child in top_level {
- Self::import_subtree(&mut self.doc, &sub_doc, child, ref_id);
- }
+ self.doc.try_graft_children(sub.doc, ref_id)?;
Ok(())
}
@@ -1258,7 +1308,7 @@
/// Unlike [`parse_content`](Self::parse_content), end of input is the
/// normal termination and an end tag (`</`) is an error (it would close
/// an element opened outside the entity — WFC violation).
- fn parse_content_fragment(&mut self, parent: NodeId) -> Result<(), ParseError> {
+ fn parse_content_fragment(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
while !self.input.at_end() {
if self.input.looking_at(b"</") {
return Err(self.input.fatal("unbalanced end tag"));
@@ -1278,20 +1328,9 @@
Ok(())
}
- /// Recursively deep-copies a subtree from `src` into `dst`, appending
- /// the copy under `dst_parent`.
- fn import_subtree(dst: &mut Document, src: &Document, src_id: NodeId, dst_parent: NodeId) {
- let kind = src.node(src_id).kind.clone();
- let new_id = dst.create_node(kind);
- dst.append_child(dst_parent, new_id);
- for child in src.children(src_id) {
- Self::import_subtree(dst, src, child, new_id);
- }
- }
-
/// Checks if the input is positioned at a builtin entity reference
/// (`&`, `<`, `>`, `'`, `"`) using byte-level
- /// checks. This avoids the `String` allocation of `peek_entity_ref_name`.
+ /// checks. Other names go directly to the ordinary XML Name parser.
#[inline]
fn is_looking_at_builtin_entity_ref(&self) -> bool {
let remaining = self.input.remaining();
@@ -1306,58 +1345,29 @@
|| after_amp.starts_with(b"quot;")
}
- /// Peeks ahead to extract the entity name from `&name;` without consuming
- /// any input. Returns `None` if the next bytes don't form a valid entity
- /// reference pattern.
- fn peek_entity_ref_name(&self) -> Option<String> {
- // We're at `&` — look ahead past it to find the name and `;`
- let remaining = self.input.remaining();
- if remaining.len() < 2 || remaining[0] != b'&' {
- return None;
- }
- let mut i = 1;
- // Collect name bytes
- let name_start = i;
- while i < remaining.len()
- && (remaining[i].is_ascii_alphanumeric()
- || remaining[i] == b'_'
- || remaining[i] == b':'
- || remaining[i] == b'-'
- || remaining[i] == b'.')
- {
- i += 1;
- }
- if i == name_start || i >= remaining.len() || remaining[i] != b';' {
- return None;
- }
- std::str::from_utf8(&remaining[name_start..i])
- .ok()
- .map(String::from)
- }
-
/// Checks if all entity references (`&name;`) in a raw attribute value
/// text are declared in the entity map. Returns false if any undeclared
/// entity ref is found (these should not be preserved via `raw_value`).
- fn all_entity_refs_declared(&self, raw: &str) -> bool {
+ fn all_entity_refs_declared(&self, raw: &str) -> Result<bool, ParseError<S>> {
+ self.storage.charge(raw.len())?;
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'&' && i + 1 < bytes.len() && bytes[i + 1] != b'#' {
- // Named entity reference — extract the name
+ // Attribute parsing already validated XML 1.0 §2.3 Name.
+ // Locate its ASCII delimiter without restricting Unicode names
+ // to an unrelated ASCII-only grammar.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#NT-Name
let mut j = i + 1;
- while j < bytes.len()
- && (bytes[j].is_ascii_alphanumeric()
- || bytes[j] == b'_'
- || bytes[j] == b':'
- || bytes[j] == b'-'
- || bytes[j] == b'.')
- {
+ while j < bytes.len() && bytes[j] != b';' {
j += 1;
}
if j < bytes.len() && bytes[j] == b';' {
- let name = std::str::from_utf8(&bytes[i + 1..j]).unwrap_or("");
- if !is_builtin_entity(name) && !self.input.entity_map.contains_key(name) {
- return false;
+ let name = &raw[i + 1..j];
+ if !is_builtin_entity(name)
+ && self.input.entity_map.get(name, self.storage)?.is_none()
+ {
+ return Ok(false);
}
i = j + 1;
} else {
@@ -1367,17 +1377,17 @@
i += 1;
}
}
- true
+ Ok(true)
}
// --- Attributes ---
// See XML 1.0 §3.1: [41] Attribute
- fn parse_attribute(&mut self) -> Result<Attribute, ParseError> {
+ fn parse_attribute(&mut self) -> Result<Attribute<S>, ParseError<S>> {
let name = self.input.parse_name()?;
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
self.input.expect_byte(b'=')?;
- self.input.skip_whitespace();
+ self.input.skip_whitespace()?;
// Capture raw attribute value text (before entity expansion) so we
// can preserve entity references during serialization.
@@ -1390,13 +1400,20 @@
// contain no '&' (most attributes have no entity references).
let raw_value = if raw_end > raw_start + 2 {
let raw_bytes = self.input.slice(raw_start + 1, raw_end - 1);
+ self.storage.charge(raw_bytes.len())?;
if raw_bytes.contains(&b'&') {
- let raw_str = std::str::from_utf8(raw_bytes).ok().map(str::to_string);
+ let raw_str = std::str::from_utf8(raw_bytes)
+ .map_err(|_| self.input.fatal("invalid UTF-8 in raw attribute value"))?;
// Only store raw_value if it differs from the expanded value
// (i.e., it contained entity references that got expanded) AND
// all entity references in the raw value are declared (not
// undeclared entities that expanded to empty string).
- raw_str.filter(|raw| *raw != value && self.all_entity_refs_declared(raw))
+ self.storage.charge(raw_str.len() + value.len())?;
+ if raw_str != value.as_ref() && self.all_entity_refs_declared(raw_str)? {
+ Some(self.storage.text(raw_str)?)
+ } else {
+ None
+ }
} else {
None
}
@@ -1404,12 +1421,12 @@
None
};
- let (prefix, local_name) = split_owned_name(name);
+ let (prefix, local_name) = split_name(name);
Ok(Attribute {
- name: local_name,
+ name: self.storage.text(local_name)?,
value,
- prefix,
+ prefix: self.storage.optional_text(prefix)?,
namespace: None,
raw_value,
})
@@ -1418,9 +1435,11 @@
// --- Comments ---
// See XML 1.0 §2.5: [15] Comment
- fn parse_comment(&mut self, parent: NodeId) -> Result<(), ParseError> {
+ fn parse_comment(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
let content = parse_comment_content(&mut self.input)?;
- let comment_id = self.doc.create_node(NodeKind::Comment { content });
+ let comment_id = self.doc.try_create_node(NodeKind::Comment {
+ content: self.storage.text(content)?,
+ })?;
self.doc.append_child(parent, comment_id);
Ok(())
}
@@ -1428,9 +1447,11 @@
// --- CDATA Sections ---
// See XML 1.0 §2.7: [18] CDSect
- fn parse_cdata(&mut self, parent: NodeId) -> Result<(), ParseError> {
+ fn parse_cdata(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
let content = parse_cdata_content(&mut self.input)?;
- let cdata_id = self.doc.create_node(NodeKind::CData { content });
+ let cdata_id = self.doc.try_create_node(NodeKind::CData {
+ content: self.storage.text(content)?,
+ })?;
self.doc.append_child(parent, cdata_id);
Ok(())
}
@@ -1438,43 +1459,27 @@
// --- Processing Instructions ---
// See XML 1.0 §2.6: [16] PI
- fn parse_processing_instruction(&mut self, parent: NodeId) -> Result<(), ParseError> {
+ fn parse_processing_instruction(&mut self, parent: NodeId) -> Result<(), ParseError<S>> {
let (target, data) = parse_pi_content(&mut self.input)?;
- let pi_id = self
- .doc
- .create_node(NodeKind::ProcessingInstruction { target, data });
+ let pi_id = self.doc.try_create_node(NodeKind::ProcessingInstruction {
+ target: self.storage.text(target)?,
+ data: self.storage.optional_text(data)?,
+ })?;
self.doc.append_child(parent, pi_id);
Ok(())
}
}
-/// Normalizes line ends per XML 1.0 §2.11: `\r\n` and lone `\r` become `\n`.
-fn normalize_line_ends(s: &str) -> String {
- let mut out = String::with_capacity(s.len());
- let mut chars = s.chars().peekable();
- while let Some(ch) = chars.next() {
- if ch == '\r' {
- if chars.peek() == Some(&'\n') {
- chars.next();
- }
- out.push('\n');
- } else {
- out.push(ch);
- }
- }
- out
-}
-
/// Strips an optional leading text declaration (`<?xml ...?>`) from external
/// parsed entity text (XML 1.0 §4.3.1: `TextDecl`).
fn strip_text_declaration(s: &str) -> &str {
let rest = s.strip_prefix("<?xml").filter(|r| {
r.starts_with(' ') || r.starts_with('\t') || r.starts_with('\r') || r.starts_with('\n')
});
- if let Some(rest) = rest {
- if let Some(end) = rest.find("?>") {
- return &rest[end + 2..];
- }
+ if let Some(rest) = rest
+ && let Some(end) = rest.find("?>")
+ {
+ return &rest[end + 2..];
}
s
}
@@ -1488,14 +1493,122 @@
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
- use crate::tree::Document;
- use pretty_assertions::assert_eq;
+ use crate::parser_xmloxide::tree::Document;
+
+ #[cfg(feature = "xmldsig")]
+ #[test]
+ fn scoped_parser_owns_dom_and_entity_fragments_in_current_execution() {
+ // The DTD tables move into runtime state; markup entities graft all
+ // sibling/parent links without a native payload copy or budget reset.
+ use crate::xml::dom::storage::Accounted;
+ use structured_asn1::{
+ Encoding, Limits, Parser,
+ memory::{Memory, MemoryAccount, MemoryError},
+ };
+ const SOURCE: &str = "<!DOCTYPE root [<!ENTITY leaf '<p:a id=\"nested\">A</p:a><p:b>B</p:b>'><!ENTITY parent '&leaf;'><!ATTLIST root xmlns:p CDATA 'urn:test' mode NMTOKEN ' on '>]><root id='root'>&parent;<tail/></root>";
+ let run = |capacity, local, aggregate| {
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(capacity, aggregate)));
+ let parser = Parser::new(
+ Encoding::Der,
+ Limits {
+ max_work: local,
+ ..Default::default()
+ },
+ memory.clone(),
+ );
+ let cursor = parser.read(&[], 0).unwrap();
+ let execution = cursor.execution();
+ let result =
+ XmlParser::with_storage(SOURCE, &ParseOptions::default(), Accounted(&execution))
+ .and_then(XmlParser::parse);
+ let refusal = match result {
+ Ok(document) => {
+ let root = document.root_element().unwrap();
+ assert_eq!(document.attribute(root, "mode"), Some(" on "));
+ let outer = document.first_child(root).unwrap();
+ let inner = document.first_child(outer).unwrap();
+ let a = document.first_child(inner).unwrap();
+ let b = document.next_sibling(a).unwrap();
+ assert_eq!(document.node_name(a), Some("a"));
+ assert_eq!(document.node_name(b), Some("b"));
+ assert_eq!(document.node_namespace(a), Some("urn:test"));
+ assert_eq!(document.parent(a), Some(inner));
+ assert_eq!(document.parent(b), Some(inner));
+ assert_eq!(document.prev_sibling(b), Some(a));
+ assert_eq!(document.last_child(inner), Some(b));
+ assert_eq!(
+ document.node_name(document.next_sibling(outer).unwrap()),
+ Some("tail")
+ );
+ assert!(memory.used() > SOURCE.len());
+ drop(document);
+ None
+ }
+ Err(crate::parser_xmloxide::error::ParseError::Resource(
+ crate::xml::dom::ParseError::SharedExecution(error),
+ )) => Some(error),
+ Err(error) => panic!("unexpected syntax or allocation failure: {error}"),
+ };
+ assert_eq!(memory.used(), 0);
+ refusal
+ };
+ assert_eq!(run(1 << 20, 1 << 24, 1 << 24), None);
+ assert_eq!(run(1 << 20, 0, 1 << 24), Some(MemoryError::WorkExceeded));
+ assert_eq!(run(1 << 20, 1 << 24, 0), Some(MemoryError::WorkExceeded));
+ assert_eq!(run(0, 1 << 24, 1 << 24), Some(MemoryError::LimitExceeded));
+ }
+
+ #[test]
+ fn undeclared_unicode_attribute_entity_has_no_retained_raw_value() {
+ // XML 1.0 Fifth Edition §2.3 Names permits Unicode entity names.
+ // An unread external subset permits omission (§4.1 Entity Declared),
+ // but an absent declaration cannot authorize retained raw output.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#NT-Name
+ let document = parse("<!DOCTYPE root SYSTEM 'urn:unread'><root a='&β;'/>");
+ let root = document.root_element().unwrap();
+ let attribute = &document.attributes(root)[0];
+ assert_eq!(attribute.value, "");
+ assert!(attribute.raw_value.is_none());
+ }
fn parse(input: &str) -> Document {
Document::parse_str(input).unwrap_or_else(|e| panic!("parse failed: {e}"))
}
#[test]
+ fn returning_document_does_not_allocate_a_replacement_arena() {
+ // Returning the completed tree must move its arena rather than create
+ // a second 64-node document only to discard that replacement parser.
+ let before = crate::parser_xmloxide::tree::ARENA_CONSTRUCTIONS.with(|count| count.get());
+ let document = parse("<root/>");
+ let after = crate::parser_xmloxide::tree::ARENA_CONSTRUCTIONS.with(|count| count.get());
+ assert_eq!(after - before, 1);
+ assert_eq!(
+ document.node_name(document.root_element().unwrap()),
+ Some("root")
+ );
+ }
+
+ #[test]
+ fn tokenized_attribute_collapse_does_not_strip_unicode_or_character_reference_tabs() {
+ // The final tokenized-attribute step collapses #x20 only. Unicode
+ // whitespace and tabs produced by character references remain data.
+ let options = ParseOptions::default();
+ let mut parser = XmlParser::new(
+ "<!DOCTYPE root [<!ATTLIST root xmlns NMTOKENS #IMPLIED>]>",
+ &options,
+ )
+ .unwrap();
+ parser.parse_doctype(parser.doc.root()).unwrap();
+ assert_eq!(
+ parser
+ .normalize_attr_value_by_type("root", "xmlns", " a b\u{a0}c \t ")
+ .unwrap(),
+ "a b\u{a0}c \t"
+ );
+ }
+
+ #[test]
fn test_parse_empty_element() {
let doc = parse("<root/>");
let root = doc.root_element().unwrap();
@@ -1660,7 +1773,7 @@
fn test_roundtrip_simple() {
let input = "<root><child>text</child></root>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(output, format!("{DECL}{input}\n"));
}
@@ -1668,7 +1781,7 @@
fn test_roundtrip_attributes() {
let input = "<root attr=\"value\"><child id=\"1\"/></root>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(output, format!("{DECL}{input}\n"));
}
@@ -1676,7 +1789,7 @@
fn test_roundtrip_entities() {
let input = "<root>& < ></root>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
// After parsing, entities are resolved to characters.
// Serialization re-escapes them.
assert_eq!(output, format!("{DECL}<root>& < ></root>\n"));
@@ -1686,7 +1799,7 @@
fn test_roundtrip_comment() {
let input = "<root><!-- comment --></root>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(output, format!("{DECL}{input}\n"));
}
@@ -1694,7 +1807,7 @@
fn test_roundtrip_cdata() {
let input = "<root><![CDATA[data & stuff]]></root>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(output, format!("{DECL}{input}\n"));
}
@@ -1702,7 +1815,7 @@
fn test_roundtrip_pi() {
let input = "<?target data?><root/>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(output, format!("{DECL}{input}\n"));
}
@@ -1710,7 +1823,7 @@
fn test_roundtrip_xml_declaration() {
let input = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(
output,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root/>\n"
@@ -1898,13 +2011,13 @@
// Simple DOCTYPE (no whitespace between DOCTYPE and root in input)
let input = "<!DOCTYPE html><html/>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(output, format!("{DECL}<!DOCTYPE html><html/>\n"));
// DOCTYPE with SYSTEM
let input = "<!DOCTYPE root SYSTEM \"root.dtd\"><root/>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(
output,
format!("{DECL}<!DOCTYPE root SYSTEM \"root.dtd\"><root/>\n")
@@ -1914,7 +2027,7 @@
let input = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0//EN\" \
\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\"><html/>";
let doc = parse(input);
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
assert_eq!(
output,
format!(
@@ -2061,7 +2174,7 @@
let element = doc.first_child(entity_ref).unwrap();
assert_eq!(doc.node_name(element), Some("b"));
// Serialization re-emits the entity reference, preserving roundtrip.
- let out = crate::serial::serialize(&doc);
+ let out = crate::parser_xmloxide::serial::serialize(&doc);
assert!(
out.contains("<d>&e;</d>"),
"unexpected serialization: {out}"
@@ -2069,6 +2182,20 @@
}
#[test]
+ fn unicode_entity_names_use_the_same_content_parser() {
+ // Dispatch must not use an ASCII-only duplicate of XML's Name grammar:
+ // a general entity includes markup, irrespective of its name spelling.
+ let doc = parse("<!DOCTYPE root [<!ENTITY \u{540d} '<child/>'>]><root>&\u{540d};</root>");
+ let root = doc.root_element().unwrap();
+ let reference = doc.first_child(root).unwrap();
+ assert!(
+ matches!(&doc.node(reference).kind, NodeKind::EntityRef { name, .. } if name == "\u{540d}")
+ );
+ let child = doc.first_child(reference).unwrap();
+ assert_eq!(doc.node_name(child), Some("child"));
+ }
+
+ #[test]
fn test_parse_entity_charref_markup() {
// "<b>hi</b>" expands to "<b>hi</b>" at declaration time
// (§4.5) and must be parsed as markup at reference time.
@@ -2086,7 +2213,7 @@
// inside the entity expansion.
let input = "<!DOCTYPE d [<!ENTITY e \"<b>hi</b>\">]><d>&e;</d>";
let doc = parse(input);
- let out = crate::serial::serialize(&doc);
+ let out = crate::parser_xmloxide::serial::serialize(&doc);
let doc2 = parse(&out);
let root2 = doc2.root_element().unwrap();
assert_eq!(doc2.text_content(root2), "hi");
@@ -2144,9 +2271,9 @@
panic!("expected depth-limited parse to fail");
};
assert!(
- err.message.contains("depth"),
+ err.syntax_message().contains("depth"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
@@ -2169,9 +2296,10 @@
panic!("expected amplification attack to be rejected");
};
assert!(
- err.message.contains("amplification") || err.message.contains("expansion limit"),
+ err.syntax_message().contains("amplification")
+ || err.syntax_message().contains("expansion limit"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
}
@@ -17,7 +17,7 @@
//!
//! # Examples
//!
-//! ```
+//! ```text,xml-sec-example
//! use xmloxide::parser::PushParser;
//!
//! let mut parser = PushParser::new();
@@ -30,10 +30,10 @@
//! assert_eq!(doc.node_name(root), Some("root"));
//! ```
-use crate::encoding::decode_to_utf8;
-use crate::error::{ParseError, SourceLocation};
-use crate::parser::ParseOptions;
-use crate::tree::Document;
+use crate::parser_xmloxide::encoding::decode_to_utf8;
+use crate::parser_xmloxide::error::{ParseError, SourceLocation};
+use crate::parser_xmloxide::parser::ParseOptions;
+use crate::parser_xmloxide::tree::Document;
/// A push-based (incremental) XML parser.
///
@@ -50,7 +50,7 @@
///
/// Basic usage with multiple chunks:
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::parser::PushParser;
///
/// let mut parser = PushParser::new();
@@ -67,7 +67,7 @@
///
/// With parse options:
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::parser::{ParseOptions, PushParser};
///
/// let opts = ParseOptions::default().recover(true);
@@ -92,7 +92,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::parser::PushParser;
///
/// let mut parser = PushParser::new();
@@ -112,7 +112,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::parser::{ParseOptions, PushParser};
///
/// let parser = PushParser::with_options(
@@ -140,7 +140,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::parser::PushParser;
///
/// let mut parser = PushParser::new();
@@ -168,7 +168,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::parser::PushParser;
///
/// let mut parser = PushParser::new();
@@ -178,13 +178,13 @@
pub fn finish(mut self) -> Result<Document, ParseError> {
self.finished = true;
- let utf8 = decode_to_utf8(&self.buffer).map_err(|e| ParseError {
+ let utf8 = decode_to_utf8(&self.buffer).map_err(|e| ParseError::Syntax {
message: e.message,
location: SourceLocation::default(),
diagnostics: Vec::new(),
})?;
- crate::parser::parse_str_with_options(&utf8, &self.options)
+ crate::parser_xmloxide::parser::parse_str_with_options(&utf8, &self.options)
}
/// Returns the number of bytes currently buffered.
@@ -194,7 +194,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::parser::PushParser;
///
/// let mut parser = PushParser::new();
@@ -211,7 +211,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::parser::PushParser;
///
/// let mut parser = PushParser::new();
@@ -232,7 +232,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::parser::PushParser;
///
/// let mut parser = PushParser::new();
@@ -268,7 +268,6 @@
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
- use pretty_assertions::assert_eq;
#[test]
fn test_push_parser_single_chunk() {
@@ -469,7 +468,7 @@
parser.push(&input[10..25]);
parser.push(&input[25..]);
let doc = parser.finish().unwrap();
- let output = crate::serial::serialize(&doc);
+ let output = crate::parser_xmloxide::serial::serialize(&doc);
let expected = format!(
"<?xml version=\"1.0\"?>\n{}\n",
std::str::from_utf8(input).unwrap()
@@ -19,8 +19,9 @@
pub use node::NodeKind;
-use crate::error::{ParseDiagnostic, ParseError};
-use std::collections::HashMap;
+use crate::parser_xmloxide::error::{ParseDiagnostic, ParseError};
+use crate::xml::dom::storage::names::{NameStorage, Names};
+use crate::xml::dom::storage::{Native, Storage, Values};
use std::num::NonZeroU32;
/// A typed index into the document's node arena.
@@ -72,10 +73,10 @@
/// Each node stores its kind (element, text, comment, etc.) and links to
/// parent, children, and siblings for tree navigation. Access individual
/// nodes via [`Document::node`].
-#[derive(Debug, Clone)]
-pub struct NodeData {
+#[derive(Debug)]
+pub struct NodeData<S: Storage = Native> {
/// What kind of node this is (element, text, comment, etc.) and its payload.
- pub kind: NodeKind,
+ pub kind: NodeKind<S>,
/// Parent node, if any. The document root node has no parent.
pub parent: Option<NodeId>,
/// First child node.
@@ -88,8 +89,8 @@
pub prev_sibling: Option<NodeId>,
}
-impl NodeData {
- fn new(kind: NodeKind) -> Self {
+impl<S: Storage> NodeData<S> {
+ fn new(kind: NodeKind<S>) -> Self {
Self {
kind,
parent: None,
@@ -101,21 +102,34 @@
}
}
+impl Clone for NodeData {
+ fn clone(&self) -> Self {
+ Self {
+ kind: self.kind.clone(),
+ parent: self.parent,
+ first_child: self.first_child,
+ last_child: self.last_child,
+ next_sibling: self.next_sibling,
+ prev_sibling: self.prev_sibling,
+ }
+ }
+}
+
/// An XML attribute on an element.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Attribute {
+pub struct Attribute<S: Storage = Native> {
/// The attribute name (the local part, e.g., `"lang"` for `xml:lang`).
- pub name: String,
+ pub name: S::Text,
/// The attribute value (fully expanded — entity references resolved).
- pub value: String,
+ pub value: S::Text,
/// Namespace prefix, if any (e.g., `"xml"` for `xml:lang`).
- pub prefix: Option<String>,
+ pub prefix: Option<S::Text>,
/// Namespace URI after resolution, if any.
- pub namespace: Option<String>,
+ pub namespace: Option<S::Text>,
/// The original attribute value text before entity expansion, if it
/// contained entity references. Used for serialization to preserve
/// entity references in the output (matching libxml2 behavior).
- pub raw_value: Option<String>,
+ pub raw_value: Option<S::Text>,
}
/// An XML document.
@@ -126,33 +140,53 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::Document;
///
/// let doc = Document::parse_str("<root/>").unwrap();
/// let root = doc.root_element().unwrap();
/// assert_eq!(doc.node_name(root), Some("root"));
/// ```
-#[derive(Debug)]
-pub struct Document {
+pub struct Document<S: NameStorage = Native> {
/// The node arena. Index 0 is unused (placeholder for `NonZeroU32`).
- nodes: Vec<NodeData>,
+ nodes: S::Values<NodeData<S>>,
/// The document root node id (the Document node, not the root element).
root: NodeId,
/// XML version from the XML declaration (e.g., "1.0").
- pub version: Option<String>,
+ pub version: Option<S::Text>,
/// Encoding from the XML declaration (e.g., "UTF-8").
- pub encoding: Option<String>,
+ pub encoding: Option<S::Text>,
/// Standalone flag from the XML declaration.
pub standalone: Option<bool>,
/// Diagnostics collected during parsing (warnings and recovered errors).
- pub diagnostics: Vec<ParseDiagnostic>,
+ pub diagnostics: S::Values<ParseDiagnostic<S::FrozenText>>,
/// Mapping from ID attribute values to element nodes.
///
/// Populated during DTD validation when attributes of type ID are
/// validated. Used by [`element_by_id`](Document::element_by_id) and
/// the `XPath` `id()` function.
- id_map: HashMap<String, NodeId>,
+ id_map: S::Names<S::Text, NodeId>,
+ storage: S,
+}
+
+impl<S: NameStorage> std::fmt::Debug for Document<S> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Document")
+ .field("nodes", &&self.nodes[..])
+ .field("root", &self.root)
+ .field("version", &self.version)
+ .field("encoding", &self.encoding)
+ .field("standalone", &self.standalone)
+ .field("diagnostics", &&self.diagnostics[..])
+ .finish()
+ }
+}
+
+#[cfg(test)]
+thread_local! {
+ pub(crate) static ARENA_CONSTRUCTIONS: std::cell::Cell<usize> = const {
+ std::cell::Cell::new(0)
+ };
}
impl Document {
@@ -167,21 +201,7 @@
/// Creates a new empty document with pre-allocated capacity for `n` nodes.
#[must_use]
pub fn with_capacity(n: usize) -> Self {
- let mut nodes = Vec::with_capacity(n);
- // Index 0: placeholder (NodeId uses NonZeroU32)
- nodes.push(NodeData::new(NodeKind::Document));
- // Index 1: the document root node
- nodes.push(NodeData::new(NodeKind::Document));
- let root = NodeId::from_index(1);
- Self {
- nodes,
- root,
- version: None,
- encoding: None,
- standalone: None,
- diagnostics: Vec::new(),
- id_map: HashMap::new(),
- }
+ Self::try_with_storage(n, Native).expect("native document arena allocation")
}
/// Parses an XML string into a `Document`.
@@ -192,7 +212,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::Document;
///
/// let doc = Document::parse_str("<root><child/></root>").unwrap();
@@ -208,9 +228,9 @@
let enc_lower = enc.to_ascii_lowercase();
if had_bom && enc_lower != "utf-8" {
// UTF-8 BOM present but encoding is not UTF-8.
- return Err(crate::error::ParseError {
+ return Err(crate::parser_xmloxide::error::ParseError::Syntax {
message: format!("UTF-8 BOM present but encoding declared as '{enc}'"),
- location: crate::error::SourceLocation::default(),
+ location: crate::parser_xmloxide::error::SourceLocation::default(),
diagnostics: Vec::new(),
});
}
@@ -222,11 +242,11 @@
|| enc_lower.starts_with("ucs")
|| enc_lower == "ebcdic";
if incompatible {
- return Err(crate::error::ParseError {
+ return Err(crate::parser_xmloxide::error::ParseError::Syntax {
message: format!(
"encoding declaration '{enc}' is incompatible with actual encoding"
),
- location: crate::error::SourceLocation::default(),
+ location: crate::parser_xmloxide::error::SourceLocation::default(),
diagnostics: Vec::new(),
});
}
@@ -237,15 +257,15 @@
&& enc_lower != "ascii"
&& encoding_rs::Encoding::for_label(enc.as_bytes()).is_none()
{
- return Err(crate::error::ParseError {
+ return Err(crate::parser_xmloxide::error::ParseError::Syntax {
message: format!("unsupported encoding '{enc}'"),
- location: crate::error::SourceLocation::default(),
+ location: crate::parser_xmloxide::error::SourceLocation::default(),
diagnostics: Vec::new(),
});
}
}
- crate::parser::parse_str(input)
+ crate::parser_xmloxide::parser::parse_str(input)
}
/// Extracts the encoding value from an XML declaration, if present.
@@ -275,7 +295,7 @@
///
/// Uses BOM sniffing and XML declaration inspection to determine the
/// encoding, then transcodes to UTF-8 before parsing. See
- /// [`crate::encoding::decode_to_utf8`] for the full detection pipeline.
+ /// [`crate::parser_xmloxide::encoding::decode_to_utf8`] for the full detection pipeline.
///
/// # Errors
///
@@ -284,7 +304,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::Document;
///
/// let doc = Document::parse_bytes(b"<root/>").unwrap();
@@ -292,10 +312,10 @@
/// assert_eq!(doc.node_name(root), Some("root"));
/// ```
pub fn parse_bytes(input: &[u8]) -> Result<Self, ParseError> {
- use crate::encoding::decode_to_utf8;
- use crate::error::SourceLocation;
+ use crate::parser_xmloxide::encoding::decode_to_utf8;
+ use crate::parser_xmloxide::error::SourceLocation;
- let utf8 = decode_to_utf8(input).map_err(|e| ParseError {
+ let utf8 = decode_to_utf8(input).map_err(|e| ParseError::Syntax {
message: e.message,
location: SourceLocation::default(),
diagnostics: Vec::new(),
@@ -304,7 +324,7 @@
// Skip BOM and encoding checks — decode_to_utf8 already handled
// encoding detection and transcoding. Go directly to the parser.
let text = utf8.strip_prefix('\u{FEFF}').unwrap_or(&utf8);
- crate::parser::parse_str(text)
+ crate::parser_xmloxide::parser::parse_str(text)
}
/// Parses an XML file from the filesystem.
@@ -319,21 +339,45 @@
///
/// # Examples
///
- /// ```no_run
+ /// ```text,xml-sec-example,no_run
/// use xmloxide::Document;
///
/// let doc = Document::parse_file("document.xml").unwrap();
/// ```
pub fn parse_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, ParseError> {
- use crate::error::SourceLocation;
+ use crate::parser_xmloxide::error::SourceLocation;
- let bytes = std::fs::read(path.as_ref()).map_err(|e| ParseError {
+ let bytes = std::fs::read(path.as_ref()).map_err(|e| ParseError::Syntax {
message: format!("failed to read file: {e}"),
location: SourceLocation::default(),
diagnostics: Vec::new(),
})?;
Self::parse_bytes(&bytes)
}
+}
+
+impl<S: NameStorage> Document<S> {
+ pub(crate) fn try_with_storage(
+ n: usize,
+ storage: S,
+ ) -> Result<Self, crate::xml::dom::ParseError> {
+ #[cfg(test)]
+ ARENA_CONSTRUCTIONS.with(|count| count.set(count.get() + 1));
+ let mut nodes = storage.values(n.max(2))?;
+ // Index 0 is the NonZeroU32 placeholder; index 1 is the root.
+ nodes.push(NodeData::new(NodeKind::Document))?;
+ nodes.push(NodeData::new(NodeKind::Document))?;
+ Ok(Self {
+ nodes,
+ root: NodeId::from_index(1),
+ version: None,
+ encoding: None,
+ standalone: None,
+ diagnostics: storage.values(0)?,
+ id_map: storage.names(),
+ storage,
+ })
+ }
/// Returns the document root `NodeId`.
///
@@ -365,13 +409,13 @@
/// Panics if `id` does not refer to a valid node in this document.
#[must_use]
#[inline]
- pub fn node(&self, id: NodeId) -> &NodeData {
+ pub fn node(&self, id: NodeId) -> &NodeData<S> {
&self.nodes[id.as_index()]
}
/// Returns a mutable reference to the `NodeData` for the given node.
#[inline]
- pub(crate) fn node_mut(&mut self, id: NodeId) -> &mut NodeData {
+ pub(crate) fn node_mut(&mut self, id: NodeId) -> &mut NodeData<S> {
&mut self.nodes[id.as_index()]
}
@@ -438,7 +482,9 @@
_ => None,
}
}
+}
+impl Document {
/// Returns the concatenated text content of a node and all its descendants.
///
/// Recursively collects text from all descendant text and CDATA nodes.
@@ -476,14 +522,16 @@
}
}
}
+}
+impl<S: NameStorage> Document<S> {
/// Returns the attributes of an element node as a slice.
///
/// Each [`Attribute`] contains the name, value, optional namespace prefix,
/// and namespace URI. Returns an empty slice for non-element nodes.
#[must_use]
#[inline]
- pub fn attributes(&self, id: NodeId) -> &[Attribute] {
+ pub fn attributes(&self, id: NodeId) -> &[Attribute<S>] {
match &self.node(id).kind {
NodeKind::Element { attributes, .. } => attributes,
_ => &[],
@@ -499,10 +547,12 @@
pub fn attribute(&self, id: NodeId, name: &str) -> Option<&str> {
self.attributes(id)
.iter()
- .find(|a| a.name == name)
- .map(|a| a.value.as_str())
+ .find(|a| a.name.as_ref() == name)
+ .map(|a| a.value.as_ref())
}
+}
+impl Document {
// --- ID lookup ---
/// Associates an ID value with an element node.
@@ -511,7 +561,7 @@
/// Subsequent calls to [`element_by_id`](Document::element_by_id) will
/// return the associated node.
pub fn set_id(&mut self, id: &str, node: NodeId) {
- self.id_map.insert(id.to_string(), node);
+ Names::insert(&mut self.id_map, id.to_string(), node).expect("native ID index allocation");
}
/// Looks up an element by its ID attribute value.
@@ -523,6 +573,36 @@
pub fn element_by_id(&self, id: &str) -> Option<NodeId> {
self.id_map.get(id).copied()
}
+}
+
+impl<S: NameStorage> Document<S> {
+ pub(crate) fn try_register_element_id(
+ &mut self,
+ node: NodeId,
+ ) -> Result<(), crate::xml::dom::ParseError> {
+ let NodeKind::Element { attributes, .. } = &self.nodes[node.as_index()].kind else {
+ return Ok(());
+ };
+ for attribute in &**attributes {
+ self.storage.charge(attribute.name.len() + 1)?;
+ if attribute.prefix.is_none() && attribute.name.as_ref() == "id" {
+ let key = self.storage.text(&attribute.value)?;
+ Names::insert(&mut self.id_map, key, node)?;
+ break;
+ }
+ }
+ Ok(())
+ }
+
+ pub(crate) fn try_set_id(
+ &mut self,
+ id: &str,
+ node: NodeId,
+ ) -> Result<(), crate::xml::dom::ParseError> {
+ let name = self.storage.text(id)?;
+ Names::insert(&mut self.id_map, name, node)?;
+ Ok(())
+ }
// --- Navigation ---
@@ -566,7 +646,7 @@
/// Yields each child `NodeId` in document order (first child to last).
/// For a depth-first traversal that includes nested descendants, use
/// [`descendants`](Self::descendants).
- pub fn children(&self, id: NodeId) -> Children<'_> {
+ pub fn children(&self, id: NodeId) -> Children<'_, S> {
Children {
doc: self,
next: self.node(id).first_child,
@@ -578,7 +658,7 @@
///
/// The first item yielded is `id` itself, followed by its parent, then
/// grandparent, and so on up to the document root node.
- pub fn ancestors(&self, id: NodeId) -> Ancestors<'_> {
+ pub fn ancestors(&self, id: NodeId) -> Ancestors<'_, S> {
Ancestors {
doc: self,
next: Some(id),
@@ -591,14 +671,16 @@
/// Does *not* yield `id` itself — only its children, grandchildren, etc.
/// For iterating only the direct children, use
/// [`children`](Self::children).
- pub fn descendants(&self, id: NodeId) -> Descendants<'_> {
+ pub fn descendants(&self, id: NodeId) -> Descendants<'_, S> {
Descendants {
doc: self,
root: id,
next: self.first_child(id),
}
}
+}
+impl Document {
// --- Mutation ---
/// Allocates a new node in the arena and returns its `NodeId`.
@@ -608,9 +690,69 @@
/// [`prepend_child`](Self::prepend_child), or
/// [`insert_before`](Self::insert_before) to attach it to the tree.
pub fn create_node(&mut self, kind: NodeKind) -> NodeId {
+ self.try_create_node(kind)
+ .expect("native document node allocation")
+ }
+}
+
+impl<S: NameStorage> Document<S> {
+ /// Moves a freshly parsed content fragment without duplicating any node
+ /// payload or walking the fragment recursively. Fragment-local ID entries
+ /// are not imported, matching the native subtree-import contract.
+ pub(crate) fn try_graft_children(
+ &mut self,
+ source: Self,
+ parent: NodeId,
+ ) -> Result<(), crate::xml::dom::ParseError> {
+ let count = source.nodes.len() - 2;
+ let base = self.nodes.len();
+ let end = base
+ .checked_add(count)
+ .ok_or(crate::xml::dom::ParseError::AllocationFailed)?;
+ if end > u32::MAX as usize {
+ return Err(crate::xml::dom::ParseError::AllocationFailed);
+ }
+ for _ in 0..6 {
+ self.storage.charge(count)?;
+ }
+ let first = source.first_child(source.root);
+ let last = source.last_child(source.root);
+ let rebase = |id: NodeId| NodeId::from_index(base + id.as_index() - 2);
+ for mut node in source.nodes.into_iter().skip(2) {
+ node.parent = node.parent.map(|id| {
+ if id == source.root {
+ parent
+ } else {
+ rebase(id)
+ }
+ });
+ node.first_child = node.first_child.map(rebase);
+ node.last_child = node.last_child.map(rebase);
+ node.next_sibling = node.next_sibling.map(rebase);
+ node.prev_sibling = node.prev_sibling.map(rebase);
+ self.nodes.push(node)?;
+ }
+ if let Some(first) = first {
+ let first = rebase(first);
+ let last = rebase(last.expect("nonempty fragment has a last child"));
+ if let Some(previous) = self.last_child(parent) {
+ self.node_mut(previous).next_sibling = Some(first);
+ self.node_mut(first).prev_sibling = Some(previous);
+ } else {
+ self.node_mut(parent).first_child = Some(first);
+ }
+ self.node_mut(parent).last_child = Some(last);
+ }
+ Ok(())
+ }
+
+ pub(crate) fn try_create_node(
+ &mut self,
+ kind: NodeKind<S>,
+ ) -> Result<NodeId, crate::xml::dom::ParseError> {
let index = self.nodes.len();
- self.nodes.push(NodeData::new(kind));
- NodeId::from_index(index)
+ self.nodes.push(NodeData::new(kind))?;
+ Ok(NodeId::from_index(index))
}
/// Appends a child node to the end of a parent's child list.
@@ -723,7 +865,9 @@
self.node_mut(id).prev_sibling = None;
self.node_mut(id).next_sibling = None;
}
+}
+impl Document {
/// Deep-copies a node and all its descendants within this document.
///
/// The cloned subtree is detached (has no parent). If `deep` is `false`,
@@ -731,7 +875,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::Document;
///
/// let mut doc = Document::parse_str("<root><child>Hello</child></root>").unwrap();
@@ -764,7 +908,7 @@
///
/// # Examples
///
- /// ```
+ /// ```text,xml-sec-example
/// use xmloxide::Document;
///
/// let mut doc = Document::parse_str("<root>old</root>").unwrap();
@@ -777,15 +921,9 @@
match &self.node(id).kind {
NodeKind::Text { .. } | NodeKind::CData { .. } | NodeKind::Comment { .. } => {
match &mut self.node_mut(id).kind {
- NodeKind::Text {
- content: ref mut c, ..
- }
- | NodeKind::CData {
- content: ref mut c, ..
- }
- | NodeKind::Comment {
- content: ref mut c, ..
- } => {
+ NodeKind::Text { content: c, .. }
+ | NodeKind::CData { content: c, .. }
+ | NodeKind::Comment { content: c, .. } => {
*c = content.to_string();
}
_ => unreachable!(),
@@ -944,7 +1082,9 @@
false
}
}
+}
+impl<S: NameStorage> Document<S> {
/// Returns the total number of nodes in the document.
///
/// This includes all node types (elements, text, comments, etc.) but
@@ -968,12 +1108,12 @@
///
/// Created by [`Document::children`]. Yields each child's `NodeId` in
/// document order by following `next_sibling` links.
-pub struct Children<'a> {
- doc: &'a Document,
+pub struct Children<'a, S: NameStorage = Native> {
+ doc: &'a Document<S>,
next: Option<NodeId>,
}
-impl Iterator for Children<'_> {
+impl<S: NameStorage> Iterator for Children<'_, S> {
type Item = NodeId;
#[inline]
@@ -988,12 +1128,12 @@
///
/// Created by [`Document::ancestors`]. The first item yielded is the starting
/// node itself, followed by its parent, grandparent, etc.
-pub struct Ancestors<'a> {
- doc: &'a Document,
+pub struct Ancestors<'a, S: NameStorage = Native> {
+ doc: &'a Document<S>,
next: Option<NodeId>,
}
-impl Iterator for Ancestors<'_> {
+impl<S: NameStorage> Iterator for Ancestors<'_, S> {
type Item = NodeId;
#[inline]
@@ -1008,13 +1148,13 @@
///
/// Created by [`Document::descendants`]. Yields every node in the subtree
/// below the starting node, but does *not* yield the starting node itself.
-pub struct Descendants<'a> {
- doc: &'a Document,
+pub struct Descendants<'a, S: NameStorage = Native> {
+ doc: &'a Document<S>,
root: NodeId,
next: Option<NodeId>,
}
-impl Iterator for Descendants<'_> {
+impl<S: NameStorage> Iterator for Descendants<'_, S> {
type Item = NodeId;
#[inline]
@@ -2033,7 +2173,12 @@
fn test_parse_file_nonexistent() {
let result = Document::parse_file("/nonexistent/path.xml");
assert!(result.is_err());
- assert!(result.unwrap_err().message.contains("failed to read file"));
+ assert!(
+ result
+ .unwrap_err()
+ .syntax_message()
+ .contains("failed to read file")
+ );
}
#[test]
@@ -5,60 +5,61 @@
//! node-type-specific payload (e.g., element name and attributes, text content).
use super::Attribute;
+use crate::xml::dom::storage::{Native, Storage};
/// The kind of an XML node and its associated data.
///
/// This enum carries the payload for each node type. Navigation links
/// (parent, children, siblings) are stored in `NodeData`, not here.
-#[derive(Debug, Clone)]
-pub enum NodeKind {
+#[derive(Clone)]
+pub enum NodeKind<S: Storage = Native> {
/// The document node — there is exactly one per `Document`.
Document,
/// An element node, e.g., `<div class="x">`.
Element {
/// The element's local name (or full `QName` before namespace resolution).
- name: String,
+ name: S::Text,
/// Namespace prefix (e.g., `"svg"` in `svg:rect`), if any.
- prefix: Option<String>,
+ prefix: Option<S::Text>,
/// Namespace URI after resolution, if any.
- namespace: Option<String>,
+ namespace: Option<S::Text>,
/// Attributes on this element.
- attributes: Vec<Attribute>,
+ attributes: S::Values<Attribute<S>>,
},
/// A text node containing character data.
Text {
/// The text content (already decoded — character references resolved).
- content: String,
+ content: S::Text,
},
/// A CDATA section, e.g., `<![CDATA[...]]>`.
CData {
/// The CDATA content (no escaping applied).
- content: String,
+ content: S::Text,
},
/// A comment node, e.g., `<!-- ... -->`.
Comment {
/// The comment text (without the `<!--` and `-->` delimiters).
- content: String,
+ content: S::Text,
},
/// A processing instruction, e.g., `<?target data?>`.
ProcessingInstruction {
/// The PI target (e.g., `"xml-stylesheet"`).
- target: String,
+ target: S::Text,
/// The PI data, if any.
- data: Option<String>,
+ data: Option<S::Text>,
},
/// An entity reference node (e.g., `&` when not expanded).
EntityRef {
/// The entity name (without `&` and `;`).
- name: String,
+ name: S::Text,
/// The expanded value of the entity (used for `text_content()`).
- value: Option<String>,
+ value: Option<S::Text>,
},
/// A document type declaration node, e.g., `<!DOCTYPE html>`.
@@ -66,12 +67,59 @@
/// See XML 1.0 §2.8: `[28]` doctypedecl
DocumentType {
/// The root element name declared in the DOCTYPE.
- name: String,
+ name: S::Text,
/// The SYSTEM identifier (URI), if any.
- system_id: Option<String>,
+ system_id: Option<S::Text>,
/// The PUBLIC identifier, if any.
- public_id: Option<String>,
+ public_id: Option<S::Text>,
/// The serialized internal subset content (between `[` and `]`), if any.
- internal_subset: Option<String>,
+ internal_subset: Option<S::Text>,
},
}
+
+impl<S: Storage> std::fmt::Debug for NodeKind<S> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Document => f.write_str("Document"),
+ Self::Element {
+ name,
+ prefix,
+ namespace,
+ attributes,
+ } => f
+ .debug_struct("Element")
+ .field("name", name)
+ .field("prefix", prefix)
+ .field("namespace", namespace)
+ .field("attributes", &&attributes[..])
+ .finish(),
+ Self::Text { content } => f.debug_struct("Text").field("content", content).finish(),
+ Self::CData { content } => f.debug_struct("CData").field("content", content).finish(),
+ Self::Comment { content } => {
+ f.debug_struct("Comment").field("content", content).finish()
+ }
+ Self::ProcessingInstruction { target, data } => f
+ .debug_struct("ProcessingInstruction")
+ .field("target", target)
+ .field("data", data)
+ .finish(),
+ Self::EntityRef { name, value } => f
+ .debug_struct("EntityRef")
+ .field("name", name)
+ .field("value", value)
+ .finish(),
+ Self::DocumentType {
+ name,
+ system_id,
+ public_id,
+ internal_subset,
+ } => f
+ .debug_struct("DocumentType")
+ .field("name", name)
+ .field("system_id", system_id)
+ .field("public_id", public_id)
+ .field("internal_subset", internal_subset)
+ .finish(),
+ }
+ }
+}
@@ -52,16 +52,16 @@
/// Diagnostics are collected when the parser operates in recovery mode,
/// allowing it to produce a partial tree even when the input is malformed.
#[derive(Debug, Clone)]
-pub struct ParseDiagnostic {
+pub struct ParseDiagnostic<L = String> {
/// The severity of this diagnostic.
pub severity: ErrorSeverity,
/// Human-readable error message.
- pub message: String,
+ pub message: L,
/// Where in the source this error occurred.
pub location: SourceLocation,
}
-impl fmt::Display for ParseDiagnostic {
+impl<L: fmt::Display> fmt::Display for ParseDiagnostic<L> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
@@ -73,29 +73,76 @@
/// The error type returned when XML parsing fails.
#[derive(Debug, Clone)]
-pub struct ParseError {
- /// The primary error message.
- pub message: String,
- /// Where in the source the fatal error occurred.
- pub location: SourceLocation,
- /// All diagnostics collected before the fatal error (in recovery mode,
- /// this includes warnings and recovered errors).
- pub diagnostics: Vec<ParseDiagnostic>,
+pub enum ParseError<L = String, D = Vec<ParseDiagnostic<L>>> {
+ /// Original syntax diagnostic and its retained, preceding diagnostics.
+ Syntax {
+ message: L,
+ location: SourceLocation,
+ diagnostics: D,
+ },
+ /// A construction refusal is terminal, not malformed XML.
+ Resource(crate::xml::dom::ParseError),
+}
+
+impl<L, D> ParseError<L, D> {
+ pub fn message(&self) -> Option<&L> {
+ match self {
+ Self::Syntax { message, .. } => Some(message),
+ Self::Resource(_) => None,
+ }
+ }
+ #[cfg(test)]
+ pub(crate) fn syntax_message(&self) -> &L {
+ self.message()
+ .expect("expected a syntax rejection, not a resource refusal")
+ }
+}
+
+impl<L, D> From<crate::xml::dom::ParseError> for ParseError<L, D> {
+ fn from(error: crate::xml::dom::ParseError) -> Self {
+ Self::Resource(error)
+ }
}
-impl fmt::Display for ParseError {
+impl<L: fmt::Display, D> fmt::Display for ParseError<L, D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "parse error at {}: {}", self.location, self.message)
+ match self {
+ Self::Syntax {
+ message, location, ..
+ } => write!(f, "parse error at {location}: {message}"),
+ Self::Resource(error) => write!(f, "XML parser construction: {error}"),
+ }
}
}
-impl std::error::Error for ParseError {}
+impl<L: fmt::Debug + fmt::Display, D: fmt::Debug> std::error::Error for ParseError<L, D> {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Resource(error) => Some(error),
+ Self::Syntax { .. } => None,
+ }
+ }
+}
#[cfg(test)]
mod tests {
use super::*;
#[test]
+ fn resource_refusal_does_not_fabricate_a_syntax_message() {
+ // An exhausted construction must retain its typed cause without
+ // allocating a label or being accepted as malformed XML instead.
+ let error: ParseError = crate::xml::dom::ParseError::AllocationFailed.into();
+ assert!(error.message().is_none());
+ assert!(
+ std::error::Error::source(&error)
+ .unwrap()
+ .downcast_ref::<crate::xml::dom::ParseError>()
+ .is_some()
+ );
+ }
+
+ #[test]
fn test_source_location_display() {
let loc = SourceLocation {
line: 10,
@@ -107,7 +154,7 @@
#[test]
fn test_parse_error_display() {
- let err = ParseError {
+ let err: ParseError = ParseError::Syntax {
message: "unexpected end of input".to_string(),
location: SourceLocation {
line: 1,
@@ -148,7 +195,7 @@
#[test]
fn test_parse_error_is_error_trait() {
- let err = ParseError {
+ let err: ParseError = ParseError::Syntax {
message: "test".to_string(),
location: SourceLocation::default(),
diagnostics: vec![],
@@ -51,7 +51,7 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::encoding::detect_encoding;
///
/// let (enc, skip) = detect_encoding(b"\xEF\xBB\xBFhello");
@@ -88,7 +88,7 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::encoding::transcode;
///
/// let result = transcode(b"hello", "UTF-8").unwrap();
@@ -158,7 +158,7 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::encoding::decode_to_utf8;
///
/// let xml = b"<?xml version=\"1.0\"?><root/>";
@@ -429,7 +429,7 @@
#[test]
fn test_parse_bytes_utf8() {
- use crate::tree::Document;
+ use crate::parser_xmloxide::tree::Document;
let result = Document::parse_bytes(b"<root/>");
assert!(result.is_ok());
@@ -440,7 +440,7 @@
#[test]
fn test_parse_bytes_utf8_with_bom() {
- use crate::tree::Document;
+ use crate::parser_xmloxide::tree::Document;
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"<root/>");
@@ -15,9 +15,6 @@
//! - Schematron validation (`schematron` submodule) for ISO Schematron rule-based validation
pub mod dtd;
-pub mod relaxng;
-pub mod schematron;
-pub mod xsd;
use std::fmt;
@@ -28,7 +25,7 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::validation::ValidationResult;
///
/// let result = ValidationResult {
@@ -18,11 +18,14 @@
//!
//! See XML 1.0 section 3.2 for the full content model specification.
-use std::collections::{HashMap, HashSet};
+use std::collections::HashSet;
use std::fmt;
-use crate::error::{ParseError, SourceLocation};
-use crate::tree::{Document, NodeId, NodeKind};
+use crate::parser_xmloxide::error::ParseError;
+use crate::parser_xmloxide::parser::input::ParserInput;
+use crate::parser_xmloxide::tree::{Document, NodeId, NodeKind};
+use crate::xml::dom::storage::names::{NameStorage, Names};
+use crate::xml::dom::storage::{Native, Storage, Values};
use super::{ValidationError, ValidationResult};
@@ -33,57 +36,83 @@
/// A parsed DTD containing all declarations from the internal subset.
///
/// This is the result of [`parse_dtd`] and serves as input to [`validate`].
-#[derive(Debug, Clone, Default)]
-pub struct Dtd {
+pub struct Dtd<S: NameStorage = Native> {
/// Element declarations, keyed by element name.
- pub elements: HashMap<String, ElementDecl>,
+ pub elements: S::Names<S::Text, ElementDecl<S>>,
/// Attribute declarations, keyed by `(element_name, attribute_name)`.
- pub attributes: HashMap<String, Vec<AttributeDecl>>,
+ pub attributes: S::Names<S::Text, S::Values<AttributeDecl<S>>>,
/// General entity declarations, keyed by entity name.
- pub entities: HashMap<String, EntityDecl>,
+ pub entities: S::Names<S::Text, EntityDecl<S>>,
/// Parameter entity declarations, keyed by entity name.
- pub param_entities: HashMap<String, EntityDecl>,
+ pub param_entities: S::Names<S::Text, EntityDecl<S>>,
/// Notation declarations, keyed by notation name.
- pub notations: HashMap<String, NotationDecl>,
+ pub notations: S::Names<S::Text, NotationDecl<S>>,
/// Ordered list of all declarations (preserving source order and comments).
- pub declarations: Vec<DtdDeclaration>,
+ pub declarations: S::Values<DtdDeclaration<S>>,
+}
+
+impl<S: NameStorage> Dtd<S> {
+ fn with_storage(storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ Ok(Self {
+ elements: storage.names(),
+ attributes: storage.names(),
+ entities: storage.names(),
+ param_entities: storage.names(),
+ notations: storage.names(),
+ declarations: storage.values(0)?,
+ })
+ }
+}
+impl Default for Dtd {
+ fn default() -> Self {
+ Self::with_storage(Native).expect("empty native DTD does not allocate")
+ }
+}
+impl fmt::Debug for Dtd {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("Dtd")
+ .field("elements", &self.elements)
+ .field("attributes", &self.attributes)
+ .field("entities", &self.entities)
+ .field("param_entities", &self.param_entities)
+ .field("notations", &self.notations)
+ .field("declarations", &self.declarations)
+ .finish()
+ }
}
/// A single DTD declaration, preserving source order for re-serialization.
-#[derive(Debug, Clone)]
-pub enum DtdDeclaration {
+pub enum DtdDeclaration<S: Storage = Native> {
/// An element declaration.
- Element(ElementDecl),
+ Element(ElementDecl<S>),
/// A single attribute declaration (one per attribute, even if the source
/// used a multi-attribute ATTLIST).
- Attlist(AttributeDecl),
+ Attlist(AttributeDecl<S>),
/// A general entity declaration.
- Entity(EntityDecl),
+ Entity(EntityDecl<S>),
/// A notation declaration.
- Notation(NotationDecl),
+ Notation(NotationDecl<S>),
/// A comment.
- Comment(String),
+ Comment(S::Text),
/// A processing instruction.
- Pi(String, Option<String>),
+ Pi(S::Text, Option<S::Text>),
}
/// An element declaration from `<!ELEMENT name content-model>`.
///
/// See XML 1.0 section 3.2.
-#[derive(Debug, Clone)]
-pub struct ElementDecl {
+pub struct ElementDecl<S: Storage = Native> {
/// The element name.
- pub name: String,
+ pub name: S::Text,
/// The declared content model.
- pub content_model: ContentModel,
+ pub content_model: ContentModel<S>,
}
/// The content model for an element declaration.
///
/// See XML 1.0 section 3.2 for the grammar:
/// - `contentspec ::= 'EMPTY' | 'ANY' | Mixed | children`
-#[derive(Debug, Clone, PartialEq)]
-pub enum ContentModel {
+pub enum ContentModel<S: Storage = Native> {
/// The element must have no children (no elements, no text).
/// Declared as `<!ELEMENT name EMPTY>`.
Empty,
@@ -94,10 +123,10 @@
/// Declared as `<!ELEMENT name (#PCDATA)>` or `<!ELEMENT name (#PCDATA|a|b)*>`.
///
/// The `Vec<String>` contains the allowed element names (empty for `#PCDATA` only).
- Mixed(Vec<String>),
+ Mixed(S::Values<S::Text>),
/// Element-only content following a content spec pattern.
/// Declared as `<!ELEMENT name (a,b,c)>` etc.
- Children(ContentSpec),
+ Children(ContentSpec<S>),
}
/// A content specification for element-only content models.
@@ -106,23 +135,21 @@
/// with occurrence indicators.
///
/// See XML 1.0 section 3.2.1 and 3.2.2.
-#[derive(Debug, Clone, PartialEq)]
-pub struct ContentSpec {
+pub struct ContentSpec<S: Storage = Native> {
/// The content particle kind.
- pub kind: ContentSpecKind,
+ pub kind: ContentSpecKind<S>,
/// How many times this particle may occur.
pub occurrence: Occurrence,
}
/// The kind of a content specification particle.
-#[derive(Debug, Clone, PartialEq)]
-pub enum ContentSpecKind {
+pub enum ContentSpecKind<S: Storage = Native> {
/// A single named element, e.g., `a`.
- Name(String),
+ Name(S::Text),
/// A sequence of particles, e.g., `(a, b, c)`.
- Seq(Vec<ContentSpec>),
+ Seq(S::Values<ContentSpec<S>>),
/// A choice among particles, e.g., `(a | b | c)`.
- Choice(Vec<ContentSpec>),
+ Choice(S::Values<ContentSpec<S>>),
}
/// Occurrence indicator for a content particle.
@@ -143,23 +170,21 @@
/// An attribute declaration from `<!ATTLIST element-name attr-name type default>`.
///
/// See XML 1.0 section 3.3.
-#[derive(Debug, Clone)]
-pub struct AttributeDecl {
+pub struct AttributeDecl<S: Storage = Native> {
/// The element this attribute belongs to.
- pub element_name: String,
+ pub element_name: S::Text,
/// The attribute name.
- pub attribute_name: String,
+ pub attribute_name: S::Text,
/// The attribute type.
- pub attribute_type: AttributeType,
+ pub attribute_type: AttributeType<S>,
/// The default value specification.
- pub default: AttributeDefault,
+ pub default: AttributeDefault<S>,
}
/// The type of an attribute as declared in `<!ATTLIST>`.
///
/// See XML 1.0 section 3.3.1.
-#[derive(Debug, Clone, PartialEq)]
-pub enum AttributeType {
+pub enum AttributeType<S: Storage = Native> {
/// Character data (`CDATA`).
CData,
/// A unique identifier (`ID`).
@@ -177,66 +202,181 @@
/// Space-separated list of name tokens (`NMTOKENS`).
NmTokens,
/// A notation type with allowed notation names (`NOTATION (a|b|c)`).
- Notation(Vec<String>),
+ Notation(S::Values<S::Text>),
/// An enumeration of allowed values (`(a|b|c)`).
- Enumeration(Vec<String>),
+ Enumeration(S::Values<S::Text>),
}
/// The default value specification for an attribute.
///
/// See XML 1.0 section 3.3.2.
-#[derive(Debug, Clone, PartialEq)]
-pub enum AttributeDefault {
+pub enum AttributeDefault<S: Storage = Native> {
/// The attribute is required (`#REQUIRED`).
Required,
/// The attribute is optional with no default (`#IMPLIED`).
Implied,
/// The attribute has a fixed value (`#FIXED "value"`).
- Fixed(String),
+ Fixed(S::Text),
/// The attribute has a default value (`"value"`).
- Default(String),
+ Default(S::Text),
}
/// A general entity declaration.
///
/// See XML 1.0 section 4.2.
-#[derive(Debug, Clone)]
-pub struct EntityDecl {
+pub struct EntityDecl<S: Storage = Native> {
/// The entity name.
- pub name: String,
+ pub name: S::Text,
/// The entity's value, either internal or external.
- pub kind: EntityKind,
+ pub kind: EntityKind<S>,
}
/// Whether an entity is internal (has a literal value) or external
/// (references an external resource).
-#[derive(Debug, Clone)]
-pub enum EntityKind {
+pub enum EntityKind<S: Storage = Native> {
/// Internal entity with a literal replacement text.
- Internal(String),
+ Internal(S::Text),
/// External entity identified by a system URI and optional public ID.
External {
/// The SYSTEM identifier (URI).
- system_id: String,
+ system_id: S::Text,
/// The PUBLIC identifier, if any.
- public_id: Option<String>,
+ public_id: Option<S::Text>,
},
}
/// A notation declaration from `<!NOTATION name ...>`.
///
/// See XML 1.0 section 4.7.
-#[derive(Debug, Clone)]
-pub struct NotationDecl {
+pub struct NotationDecl<S: Storage = Native> {
/// The notation name.
- pub name: String,
+ pub name: S::Text,
/// The SYSTEM identifier, if any.
- pub system_id: Option<String>,
+ pub system_id: Option<S::Text>,
/// The PUBLIC identifier, if any.
- pub public_id: Option<String>,
+ pub public_id: Option<S::Text>,
+}
+
+impl<S: Storage> ContentModel<S> {
+ fn copy_in(&self, storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ storage.charge(1)?;
+ Ok(match self {
+ Self::Empty => Self::Empty,
+ Self::Any => Self::Any,
+ Self::Mixed(names) => Self::Mixed(copy_names(names, storage)?),
+ Self::Children(spec) => Self::Children(spec.copy_in(storage)?),
+ })
+ }
+}
+
+fn copy_names<S: Storage>(
+ names: &[S::Text],
+ storage: S,
+) -> Result<S::Values<S::Text>, crate::xml::dom::ParseError> {
+ storage.charge(names.len())?;
+ let mut copied = storage.values(names.len())?;
+ for name in names {
+ copied.push(storage.text(name)?)?;
+ }
+ Ok(copied)
+}
+
+impl<S: Storage> ContentSpec<S> {
+ fn copy_in(&self, storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ // Only the parser creates particles. Its fixed content-model depth
+ // ceiling bounds this traversal and subsequent owning destruction.
+ storage.charge(1)?;
+ let kind = match &self.kind {
+ ContentSpecKind::Name(name) => ContentSpecKind::Name(storage.text(name)?),
+ ContentSpecKind::Seq(items) | ContentSpecKind::Choice(items) => {
+ storage.charge(items.len())?;
+ let mut copied = storage.values(items.len())?;
+ for item in &**items {
+ copied.push(item.copy_in(storage)?)?;
+ }
+ if matches!(self.kind, ContentSpecKind::Seq(_)) {
+ ContentSpecKind::Seq(copied)
+ } else {
+ ContentSpecKind::Choice(copied)
+ }
+ }
+ };
+ Ok(Self {
+ kind,
+ occurrence: self.occurrence,
+ })
+ }
+}
+
+impl<S: Storage> ElementDecl<S> {
+ fn copy_in(&self, storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ Ok(Self {
+ name: storage.text(&self.name)?,
+ content_model: self.content_model.copy_in(storage)?,
+ })
+ }
}
-impl fmt::Display for ContentModel {
+impl<S: Storage> AttributeDecl<S> {
+ fn copy_in(&self, storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ let attribute_type = match &self.attribute_type {
+ AttributeType::CData => AttributeType::CData,
+ AttributeType::Id => AttributeType::Id,
+ AttributeType::IdRef => AttributeType::IdRef,
+ AttributeType::IdRefs => AttributeType::IdRefs,
+ AttributeType::Entity => AttributeType::Entity,
+ AttributeType::Entities => AttributeType::Entities,
+ AttributeType::NmToken => AttributeType::NmToken,
+ AttributeType::NmTokens => AttributeType::NmTokens,
+ AttributeType::Notation(names) => AttributeType::Notation(copy_names(names, storage)?),
+ AttributeType::Enumeration(names) => {
+ AttributeType::Enumeration(copy_names(names, storage)?)
+ }
+ };
+ let default = match &self.default {
+ AttributeDefault::Required => AttributeDefault::Required,
+ AttributeDefault::Implied => AttributeDefault::Implied,
+ AttributeDefault::Fixed(value) => AttributeDefault::Fixed(storage.text(value)?),
+ AttributeDefault::Default(value) => AttributeDefault::Default(storage.text(value)?),
+ };
+ Ok(Self {
+ element_name: storage.text(&self.element_name)?,
+ attribute_name: storage.text(&self.attribute_name)?,
+ attribute_type,
+ default,
+ })
+ }
+}
+
+impl<S: Storage> EntityDecl<S> {
+ fn copy_in(&self, storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ Ok(Self {
+ name: storage.text(&self.name)?,
+ kind: match &self.kind {
+ EntityKind::Internal(value) => EntityKind::Internal(storage.text(value)?),
+ EntityKind::External {
+ system_id,
+ public_id,
+ } => EntityKind::External {
+ system_id: storage.text(system_id)?,
+ public_id: storage.optional_text(public_id.as_deref())?,
+ },
+ },
+ })
+ }
+}
+
+impl<S: Storage> NotationDecl<S> {
+ fn copy_in(&self, storage: S) -> Result<Self, crate::xml::dom::ParseError> {
+ Ok(Self {
+ name: storage.text(&self.name)?,
+ system_id: storage.optional_text(self.system_id.as_deref())?,
+ public_id: storage.optional_text(self.public_id.as_deref())?,
+ })
+ }
+}
+
+impl<S: Storage> fmt::Display for ContentModel<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "EMPTY"),
@@ -245,7 +385,11 @@
if names.is_empty() {
write!(f, "(#PCDATA)")
} else {
- write!(f, "(#PCDATA|{})*", names.join("|"))
+ f.write_str("(#PCDATA")?;
+ for name in &**names {
+ write!(f, "|{name}")?;
+ }
+ f.write_str(")*")
}
}
Self::Children(spec) => write!(f, "{spec}"),
@@ -253,7 +397,7 @@
}
}
-impl fmt::Display for ContentSpec {
+impl<S: Storage> fmt::Display for ContentSpec<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
ContentSpecKind::Name(name) => write!(f, "{name}")?,
@@ -288,6 +432,149 @@
}
}
+// The upstream validation/serialization API is native. Scoped construction
+// does not require Clone, Debug or infallible formatting of its containers.
+macro_rules! native_debug_struct {
+ ($type:ident, $($field:ident),+ $(,)?) => {
+ impl fmt::Debug for $type {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct(stringify!($type))$(.field(stringify!($field), &self.$field))+.finish()
+ }
+ }
+ };
+}
+native_debug_struct!(ElementDecl, name, content_model);
+native_debug_struct!(ContentSpec, kind, occurrence);
+native_debug_struct!(
+ AttributeDecl,
+ element_name,
+ attribute_name,
+ attribute_type,
+ default
+);
+native_debug_struct!(EntityDecl, name, kind);
+native_debug_struct!(NotationDecl, name, system_id, public_id);
+
+impl fmt::Debug for ContentModel {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Empty => f.write_str("Empty"),
+ Self::Any => f.write_str("Any"),
+ Self::Mixed(values) => f.debug_tuple("Mixed").field(values).finish(),
+ Self::Children(value) => f.debug_tuple("Children").field(value).finish(),
+ }
+ }
+}
+impl PartialEq for ContentModel {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ (Self::Empty, Self::Empty) | (Self::Any, Self::Any) => true,
+ (Self::Mixed(left), Self::Mixed(right)) => left == right,
+ (Self::Children(left), Self::Children(right)) => left == right,
+ _ => false,
+ }
+ }
+}
+impl PartialEq for ContentSpec {
+ fn eq(&self, other: &Self) -> bool {
+ self.occurrence == other.occurrence && self.kind == other.kind
+ }
+}
+impl fmt::Debug for ContentSpecKind {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Name(value) => f.debug_tuple("Name").field(value).finish(),
+ Self::Seq(values) => f.debug_tuple("Seq").field(values).finish(),
+ Self::Choice(values) => f.debug_tuple("Choice").field(values).finish(),
+ }
+ }
+}
+impl PartialEq for ContentSpecKind {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ (Self::Name(left), Self::Name(right)) => left == right,
+ (Self::Seq(left), Self::Seq(right)) | (Self::Choice(left), Self::Choice(right)) => {
+ left == right
+ }
+ _ => false,
+ }
+ }
+}
+impl fmt::Debug for AttributeType {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::CData => f.write_str("CData"),
+ Self::Id => f.write_str("Id"),
+ Self::IdRef => f.write_str("IdRef"),
+ Self::IdRefs => f.write_str("IdRefs"),
+ Self::Entity => f.write_str("Entity"),
+ Self::Entities => f.write_str("Entities"),
+ Self::NmToken => f.write_str("NmToken"),
+ Self::NmTokens => f.write_str("NmTokens"),
+ Self::Notation(values) => f.debug_tuple("Notation").field(values).finish(),
+ Self::Enumeration(values) => f.debug_tuple("Enumeration").field(values).finish(),
+ }
+ }
+}
+impl PartialEq for AttributeType {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ (Self::Notation(left), Self::Notation(right))
+ | (Self::Enumeration(left), Self::Enumeration(right)) => left == right,
+ (Self::Notation(_) | Self::Enumeration(_), _)
+ | (_, Self::Notation(_) | Self::Enumeration(_)) => false,
+ _ => core::mem::discriminant(self) == core::mem::discriminant(other),
+ }
+ }
+}
+impl fmt::Debug for AttributeDefault {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Required => f.write_str("Required"),
+ Self::Implied => f.write_str("Implied"),
+ Self::Fixed(value) => f.debug_tuple("Fixed").field(value).finish(),
+ Self::Default(value) => f.debug_tuple("Default").field(value).finish(),
+ }
+ }
+}
+impl PartialEq for AttributeDefault {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ (Self::Required, Self::Required) | (Self::Implied, Self::Implied) => true,
+ (Self::Fixed(left), Self::Fixed(right))
+ | (Self::Default(left), Self::Default(right)) => left == right,
+ _ => false,
+ }
+ }
+}
+impl fmt::Debug for EntityKind {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Internal(value) => f.debug_tuple("Internal").field(value).finish(),
+ Self::External {
+ system_id,
+ public_id,
+ } => f
+ .debug_struct("External")
+ .field("system_id", system_id)
+ .field("public_id", public_id)
+ .finish(),
+ }
+ }
+}
+impl fmt::Debug for DtdDeclaration {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Element(value) => f.debug_tuple("Element").field(value).finish(),
+ Self::Attlist(value) => f.debug_tuple("Attlist").field(value).finish(),
+ Self::Entity(value) => f.debug_tuple("Entity").field(value).finish(),
+ Self::Notation(value) => f.debug_tuple("Notation").field(value).finish(),
+ Self::Comment(value) => f.debug_tuple("Comment").field(value).finish(),
+ Self::Pi(target, data) => f.debug_tuple("Pi").field(target).field(data).finish(),
+ }
+ }
+}
+
// ---------------------------------------------------------------------------
// DTD Serializer
// ---------------------------------------------------------------------------
@@ -300,106 +587,111 @@
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn serialize_dtd(dtd: &Dtd) -> String {
- let mut out = String::new();
+ serialize_dtd_in(dtd, Native).expect("native DTD serialization allocation")
+}
+
+struct DtdOutput<S: Storage> {
+ storage: S,
+ text: S::Text,
+}
+
+impl<S: Storage> DtdOutput<S> {
+ fn push_str(&mut self, value: &str) -> Result<(), crate::xml::dom::ParseError> {
+ self.storage.append(&mut self.text, value)
+ }
+ fn push(&mut self, value: char) -> Result<(), crate::xml::dom::ParseError> {
+ self.push_str(value.encode_utf8(&mut [0; 4]))
+ }
+ fn format(&mut self, value: fmt::Arguments<'_>) -> Result<(), crate::xml::dom::ParseError> {
+ self.storage.format_into(&mut self.text, value)
+ }
+}
+
+pub(crate) fn serialize_dtd_in<R: NameStorage, S: Storage>(
+ dtd: &Dtd<R>,
+ storage: S,
+) -> Result<S::Text, crate::xml::dom::ParseError> {
+ let mut out = DtdOutput {
+ storage,
+ text: storage.text("")?,
+ };
let mut last_was_comment = false;
- for decl in &dtd.declarations {
+ for decl in &*dtd.declarations {
+ storage.charge(1)?;
// Don't add a newline before declarations that immediately follow
// a comment — the comment text already contains any needed whitespace.
// libxml2 concatenates the comment closing `-->` and the next
// declaration on the same line.
if !last_was_comment {
- out.push('\n');
+ out.push('\n')?;
}
match decl {
DtdDeclaration::Element(e) => {
- out.push_str("<!ELEMENT ");
- out.push_str(&e.name);
- out.push(' ');
- write_content_model(&mut out, &e.content_model);
- out.push('>');
+ out.format(format_args!("<!ELEMENT {} ", e.name))?;
+ write_content_model(&mut out, &e.content_model)?;
+ out.push('>')?;
last_was_comment = false;
}
DtdDeclaration::Attlist(a) => {
- out.push_str("<!ATTLIST ");
- out.push_str(&a.element_name);
- out.push(' ');
- out.push_str(&a.attribute_name);
- out.push(' ');
- write_attribute_type(&mut out, &a.attribute_type);
- out.push(' ');
- write_attribute_default(&mut out, &a.default);
- out.push('>');
+ out.format(format_args!(
+ "<!ATTLIST {} {} ",
+ a.element_name, a.attribute_name
+ ))?;
+ write_attribute_type(&mut out, &a.attribute_type)?;
+ out.push(' ')?;
+ write_attribute_default(&mut out, &a.default)?;
+ out.push('>')?;
last_was_comment = false;
}
DtdDeclaration::Entity(e) => {
- out.push_str("<!ENTITY ");
- out.push_str(&e.name);
+ out.format(format_args!("<!ENTITY {}", e.name))?;
match &e.kind {
EntityKind::Internal(value) => {
- out.push(' ');
- write_entity_value(&mut out, value);
+ out.push(' ')?;
+ write_entity_value(&mut out, value)?;
}
EntityKind::External {
system_id,
public_id,
} => {
if let Some(pub_id) = public_id {
- out.push_str(" PUBLIC \"");
- out.push_str(pub_id);
- out.push_str("\" \"");
- out.push_str(system_id);
- out.push('"');
+ out.format(format_args!(" PUBLIC \"{pub_id}\" \"{system_id}\""))?;
} else {
- out.push_str(" SYSTEM \"");
- out.push_str(system_id);
- out.push('"');
+ out.format(format_args!(" SYSTEM \"{system_id}\""))?;
}
}
}
- out.push('>');
+ out.push('>')?;
last_was_comment = false;
}
DtdDeclaration::Notation(n) => {
- out.push_str("<!NOTATION ");
- out.push_str(&n.name);
+ out.format(format_args!("<!NOTATION {}", n.name))?;
match (&n.public_id, &n.system_id) {
(Some(pub_id), Some(sys_id)) => {
- out.push_str(" PUBLIC \"");
- out.push_str(pub_id);
- out.push_str("\" \"");
- out.push_str(sys_id);
- out.push('"');
+ out.format(format_args!(" PUBLIC \"{pub_id}\" \"{sys_id}\""))?;
}
(Some(pub_id), None) => {
- out.push_str(" PUBLIC \"");
- out.push_str(pub_id);
- out.push('"');
+ out.format(format_args!(" PUBLIC \"{pub_id}\""))?;
}
(None, Some(sys_id)) => {
- out.push_str(" SYSTEM \"");
- out.push_str(sys_id);
- out.push('"');
+ out.format(format_args!(" SYSTEM \"{sys_id}\""))?;
}
(None, None) => {}
}
- out.push('>');
+ out.push('>')?;
last_was_comment = false;
}
DtdDeclaration::Comment(text) => {
- out.push_str("<!--");
- out.push_str(text);
- out.push_str("-->");
+ out.format(format_args!("<!--{text}-->"))?;
last_was_comment = true;
}
DtdDeclaration::Pi(target, data) => {
- out.push_str("<?");
- out.push_str(target);
+ out.format(format_args!("<?{target}"))?;
if let Some(d) = data {
- out.push(' ');
- out.push_str(d);
+ out.format(format_args!(" {d}"))?;
}
- out.push_str("?>");
+ out.push_str("?>")?;
last_was_comment = false;
}
}
@@ -407,79 +699,85 @@
// libxml2 adds a newline before ]> unless the last item was a comment.
if !last_was_comment && !dtd.declarations.is_empty() {
- out.push('\n');
+ out.push('\n')?;
}
- out
+ Ok(out.text)
}
/// Writes a content model in libxml2's format.
-fn write_content_model(out: &mut String, model: &ContentModel) {
+fn write_content_model<R: Storage, S: Storage>(
+ out: &mut DtdOutput<S>,
+ model: &ContentModel<R>,
+) -> Result<(), crate::xml::dom::ParseError> {
match model {
- ContentModel::Empty => out.push_str("EMPTY"),
- ContentModel::Any => out.push_str("ANY"),
+ ContentModel::Empty => out.push_str("EMPTY")?,
+ ContentModel::Any => out.push_str("ANY")?,
ContentModel::Mixed(names) => {
if names.is_empty() {
- out.push_str("(#PCDATA)");
+ out.push_str("(#PCDATA)")?;
} else {
- out.push_str("(#PCDATA");
- for name in names {
- out.push_str(" | ");
- out.push_str(name);
+ out.push_str("(#PCDATA")?;
+ for name in &**names {
+ out.format(format_args!(" | {name}"))?;
}
- out.push_str(")*");
+ out.push_str(")*")?;
}
}
ContentModel::Children(spec) => {
- use std::fmt::Write;
- let _ = write!(out, "{spec}");
+ out.format(format_args!("{spec}"))?;
}
}
+ Ok(())
}
/// Writes an attribute type in libxml2's format.
-fn write_attribute_type(out: &mut String, attr_type: &AttributeType) {
+fn write_attribute_type<R: Storage, S: Storage>(
+ out: &mut DtdOutput<S>,
+ attr_type: &AttributeType<R>,
+) -> Result<(), crate::xml::dom::ParseError> {
match attr_type {
- AttributeType::CData => out.push_str("CDATA"),
- AttributeType::Id => out.push_str("ID"),
- AttributeType::IdRef => out.push_str("IDREF"),
- AttributeType::IdRefs => out.push_str("IDREFS"),
- AttributeType::Entity => out.push_str("ENTITY"),
- AttributeType::Entities => out.push_str("ENTITIES"),
- AttributeType::NmToken => out.push_str("NMTOKEN"),
- AttributeType::NmTokens => out.push_str("NMTOKENS"),
+ AttributeType::CData => out.push_str("CDATA")?,
+ AttributeType::Id => out.push_str("ID")?,
+ AttributeType::IdRef => out.push_str("IDREF")?,
+ AttributeType::IdRefs => out.push_str("IDREFS")?,
+ AttributeType::Entity => out.push_str("ENTITY")?,
+ AttributeType::Entities => out.push_str("ENTITIES")?,
+ AttributeType::NmToken => out.push_str("NMTOKEN")?,
+ AttributeType::NmTokens => out.push_str("NMTOKENS")?,
AttributeType::Notation(values) | AttributeType::Enumeration(values) => {
if matches!(attr_type, AttributeType::Notation(_)) {
- out.push_str("NOTATION ");
+ out.push_str("NOTATION ")?;
}
- out.push('(');
+ out.push('(')?;
for (i, v) in values.iter().enumerate() {
if i > 0 {
- out.push_str(" | ");
+ out.push_str(" | ")?;
}
- out.push_str(v);
+ out.push_str(v)?;
}
- out.push(')');
+ out.push(')')?;
}
}
+ Ok(())
}
/// Writes an attribute default in libxml2's format.
-fn write_attribute_default(out: &mut String, default: &AttributeDefault) {
+fn write_attribute_default<R: Storage, S: Storage>(
+ out: &mut DtdOutput<S>,
+ default: &AttributeDefault<R>,
+) -> Result<(), crate::xml::dom::ParseError> {
match default {
- AttributeDefault::Required => out.push_str("#REQUIRED"),
- AttributeDefault::Implied => out.push_str("#IMPLIED"),
+ AttributeDefault::Required => out.push_str("#REQUIRED")?,
+ AttributeDefault::Implied => out.push_str("#IMPLIED")?,
AttributeDefault::Fixed(value) => {
- out.push_str("#FIXED \"");
- out.push_str(value);
- out.push('"');
+ out.format(format_args!("#FIXED \"{value}\""))?;
}
AttributeDefault::Default(value) => {
- out.push('"');
- out.push_str(value);
- out.push('"');
+ out.format(format_args!("\"{value}\""))?;
}
}
+ Ok(())
}
/// Escapes an entity value for DTD serialization.
@@ -488,7 +786,15 @@
/// the value are preserved as-is (matching libxml2 behavior). Only standalone
/// `&` characters are escaped. The quote character is chosen to minimize
/// escaping: single quotes when the value contains double quotes.
-fn write_entity_value(out: &mut String, value: &str) {
+fn write_entity_value<S: Storage>(
+ out: &mut DtdOutput<S>,
+ value: &str,
+) -> Result<(), crate::xml::dom::ParseError> {
+ // Quote selection, escaping/reference recognition, and reference UTF-8
+ // validation are bounded passes over this actual entity value.
+ for _ in 0..5 {
+ out.storage.charge(value.len())?;
+ }
// Choose quote character: use single quotes if value contains double quotes
// but not single quotes (avoids escaping). Otherwise use double quotes.
let quote = if value.contains('"') && !value.contains('\'') {
@@ -496,7 +802,7 @@
} else {
'"'
};
- out.push(quote);
+ out.push(quote)?;
let bytes = value.as_bytes();
let len = bytes.len();
@@ -508,27 +814,27 @@
if let Some(ref_end) = find_reference_end(bytes, i) {
// Copy the reference as-is
let ref_str = std::str::from_utf8(&bytes[i..=ref_end]).unwrap_or("&");
- out.push_str(ref_str);
+ out.push_str(ref_str)?;
i = ref_end + 1;
} else {
- out.push_str("&");
+ out.push_str("&")?;
i += 1;
}
} else if bytes[i] == b'%' {
- out.push_str("%");
+ out.push_str("%")?;
i += 1;
} else if bytes[i] == quote as u8 {
if quote == '"' {
- out.push_str(""");
+ out.push_str(""")?;
} else {
- out.push_str("'");
+ out.push_str("'")?;
}
i += 1;
} else {
// Push the char (may be multi-byte UTF-8)
let ch = &value[i..];
if let Some(c) = ch.chars().next() {
- out.push(c);
+ out.push(c)?;
i += c.len_utf8();
} else {
i += 1;
@@ -536,7 +842,8 @@
}
}
- out.push(quote);
+ out.push(quote)?;
+ Ok(())
}
/// Finds the end position (inclusive, the `;`) of an entity or character
@@ -618,49 +925,64 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::validation::dtd::parse_dtd;
///
/// let dtd = parse_dtd("<!ELEMENT root (#PCDATA)>").unwrap();
/// assert!(dtd.elements.contains_key("root"));
/// ```
pub fn parse_dtd(input: &str) -> Result<Dtd, ParseError> {
- let mut parser = DtdParser::new(input);
- parser.parse()
+ DtdParser::new(input).parse()
+}
+
+type DtdError<S> = ParseError<
+ <S as Storage>::FrozenText,
+ <S as Storage>::Values<
+ crate::parser_xmloxide::error::ParseDiagnostic<<S as Storage>::FrozenText>,
+ >,
+>;
+
+pub(crate) fn parse_dtd_in<S: NameStorage>(input: &str, storage: S) -> Result<Dtd<S>, DtdError<S>> {
+ DtdParser::with_storage(input, storage)?.parse()
}
/// Maximum nesting depth for `<!ELEMENT>` content-model groups.
///
-/// Bounds the mutual recursion between [`DtdParser::parse_content_spec_group`]
-/// and [`DtdParser::parse_content_particle`] so that a deeply-nested
-/// parenthesised content model cannot exhaust the stack (CWE-674). Matches
-/// the spirit of the parser's `DEFAULT_MAX_DEPTH`, which bounds element
-/// nesting but does not reach the DTD content-model grammar.
+/// The iterative parser enforces this ceiling for the retained model's
+/// formatting, copying and destruction as well as parsing (CWE-674).
const MAX_CONTENT_MODEL_DEPTH: u32 = 256;
/// Internal DTD parser state.
-struct DtdParser<'a> {
- input: &'a [u8],
- pos: usize,
- line: u32,
- column: u32,
- dtd: Dtd,
+struct DtdParser<'a, S: NameStorage = Native> {
+ input: ParserInput<'a, S>,
+ dtd: Dtd<S>,
+ storage: S,
}
impl<'a> DtdParser<'a> {
fn new(input: &'a str) -> Self {
- Self {
- input: input.as_bytes(),
- pos: 0,
- line: 1,
- column: 1,
- dtd: Dtd::default(),
- }
+ Self::with_storage(input, Native).expect("empty native DTD does not allocate")
+ }
+}
+
+impl<'a, S: NameStorage> DtdParser<'a, S> {
+ fn with_storage(input: &'a str, storage: S) -> Result<Self, DtdError<S>> {
+ Ok(Self {
+ input: {
+ let mut cursor = ParserInput::with_storage(input, storage)?;
+ // DTD names share the XML grammar. The adapter's compiled
+ // preflight, not a second parser default, owns name limits.
+ cursor.set_max_name_length(usize::MAX);
+ cursor
+ },
+ dtd: Dtd::with_storage(storage)?,
+ storage,
+ })
}
- fn parse(&mut self) -> Result<Dtd, ParseError> {
+ fn parse(mut self) -> Result<Dtd<S>, DtdError<S>> {
loop {
- self.skip_whitespace();
+ self.skip_whitespace()?;
if self.at_end() {
break;
}
@@ -681,7 +1003,7 @@
// Parameter entity reference — skip it since we don't expand
self.skip_pe_reference()?;
} else {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"unexpected character '{}' in DTD",
self.peek().map_or('?', |b| b as char)
)));
@@ -690,7 +1012,7 @@
self.post_validate()?;
- Ok(std::mem::take(&mut self.dtd))
+ Ok(self.dtd)
}
/// Post-parse validation checks that require the complete entity map.
@@ -698,21 +1020,21 @@
/// Detects entity recursion (WFC: No Recursion), validates that entity
/// references in attribute defaults refer to internal parsed entities,
/// and checks for `<` in entity replacement text used in attributes.
- fn post_validate(&self) -> Result<(), ParseError> {
+ fn post_validate(&self) -> Result<(), DtdError<S>> {
// Check for entity recursion (WFC: No Recursion, XML 1.0 §4.1).
// `safe` memoizes entities already proven acyclic so the walk is
// linear in the total size of the entity declarations rather than
// exponential in chain depth.
- let mut safe = std::collections::HashSet::new();
- for (name, decl) in &self.dtd.entities {
+ let mut safe = self.storage.names();
+ for (name, decl) in self.dtd.entities.entries(self.storage)? {
if let EntityKind::Internal(ref value) = decl.kind {
- if safe.contains(name.as_str()) {
+ if safe.get(name.as_ref(), self.storage)?.is_some() {
continue;
}
- let mut visited = std::collections::HashSet::new();
- visited.insert(name.clone());
+ let mut visited = self.storage.names();
+ Names::insert(&mut visited, name.as_ref(), ())?;
self.check_entity_recursion(value, &mut visited, &mut safe)?;
- safe.insert(name.clone());
+ Names::insert(&mut safe, name.as_ref(), ())?;
}
}
@@ -720,17 +1042,17 @@
// PE values may contain encoded PE references via % (which is '%').
// After char ref expansion, if %name; appears in its own value, that's
// direct or indirect recursion.
- let mut safe = std::collections::HashSet::new();
- for (name, decl) in &self.dtd.param_entities {
+ let mut safe = self.storage.names();
+ for (name, decl) in self.dtd.param_entities.entries(self.storage)? {
if let EntityKind::Internal(ref value) = decl.kind {
- if safe.contains(name.as_str()) {
+ if safe.get(name.as_ref(), self.storage)?.is_some() {
continue;
}
- let expanded = expand_char_refs_only(value);
- let mut visited = std::collections::HashSet::new();
- visited.insert(name.clone());
+ let expanded = expand_char_refs_only(value, self.storage)?;
+ let mut visited = self.storage.names();
+ Names::insert(&mut visited, name.as_ref(), ())?;
self.check_pe_recursion(&expanded, &mut visited, &mut safe)?;
- safe.insert(name.clone());
+ Names::insert(&mut safe, name.as_ref(), ())?;
}
}
@@ -738,9 +1060,9 @@
// expansion (XML 1.0 §4.5). Character references in entity
// values are expanded at declaration time. The resulting
// replacement text must be well-formed when re-parsed.
- for (name, decl) in &self.dtd.entities {
+ for (name, decl) in self.dtd.entities.entries(self.storage)? {
if let EntityKind::Internal(ref value) = decl.kind {
- self.validate_replacement_text(name, value)?;
+ self.validate_replacement_text(name.as_ref(), value)?;
}
}
@@ -757,9 +1079,10 @@
// Validate entity references in ATTLIST defaults. `checked`
// memoizes entities already validated so shared references are
// walked once (the reference graph is acyclic at this point).
- let mut checked = std::collections::HashSet::new();
- for attrs in self.dtd.attributes.values() {
- for attr in attrs {
+ let mut checked = self.storage.names();
+ for (_, attrs) in self.dtd.attributes.entries(self.storage)? {
+ self.storage.charge(attrs.len())?;
+ for attr in &**attrs {
let (AttributeDefault::Default(default_value)
| AttributeDefault::Fixed(default_value)) = &attr.default
else {
@@ -778,7 +1101,7 @@
/// Per XML 1.0 §4.6: "If the entities lt or amp are declared, they MUST
/// be declared as internal entities whose replacement text is a character
/// reference to the respective character."
- fn validate_predefined_entities(&self) -> Result<(), ParseError> {
+ fn validate_predefined_entities(&self) -> Result<(), DtdError<S>> {
// Per XML 1.0 §4.6: lt and amp MUST use character references.
// gt, apos, and quot may use either the literal character or a
// character reference.
@@ -790,13 +1113,16 @@
("quot", "\"", &["\"", """, """]),
];
for &(name, _char_val, valid_refs) in expected {
- if let Some(decl) = self.dtd.entities.get(name) {
+ if let Some(decl) = self.dtd.entities.get(name, self.storage)? {
match &decl.kind {
EntityKind::Internal(value) => {
// Check if the value is a valid character reference
// for this predefined entity.
- if !valid_refs.iter().any(|r| r == value) {
- return Err(self.fatal(format!(
+ for _ in valid_refs {
+ self.storage.charge(value.len())?;
+ }
+ if !valid_refs.iter().any(|r| *r == value.as_ref()) {
+ return Err(self.fatal(format_args!(
"predefined entity '{name}' must be declared as \
a character reference (e.g., '{}')",
valid_refs[0]
@@ -804,7 +1130,7 @@
}
}
EntityKind::External { .. } => {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"predefined entity '{name}' must be an internal entity"
)));
}
@@ -821,24 +1147,26 @@
/// references), then checks the resulting replacement text for basic
/// well-formedness: bare `&` characters from `&` expansion that
/// don't form valid references are rejected.
- fn validate_replacement_text(&self, entity_name: &str, value: &str) -> Result<(), ParseError> {
+ fn validate_replacement_text(&self, entity_name: &str, value: &str) -> Result<(), DtdError<S>> {
// Only check values that contain character references
+ self.storage.charge(value.len())?;
if !value.contains("&#") {
return Ok(());
}
// Build replacement text by expanding only character references
- let replacement = Self::expand_char_refs_only(value);
+ let replacement = expand_char_refs_only(value, self.storage)?;
// Check for bare '&' in the replacement text that don't form
// valid entity or character references
+ self.storage.charge(replacement.len())?;
let bytes = replacement.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'&' {
i += 1;
if i >= bytes.len() {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"entity '{entity_name}' replacement text contains \
bare '&' at end of text"
)));
@@ -861,7 +1189,7 @@
i > start
};
if !has_digits || i >= bytes.len() || bytes[i] != b';' {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"entity '{entity_name}' replacement text contains \
incomplete character reference"
)));
@@ -873,14 +1201,14 @@
i += 1;
}
if i >= bytes.len() {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"entity '{entity_name}' replacement text contains \
incomplete entity reference"
)));
}
i += 1;
} else {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"entity '{entity_name}' replacement text contains \
bare '&' not followed by a valid reference"
)));
@@ -892,74 +1220,102 @@
Ok(())
}
- /// Expands only character references in a string, leaving entity
- /// references as-is. Returns the expanded text.
- fn expand_char_refs_only(value: &str) -> String {
- expand_char_refs_only(value)
- }
-
- /// Recursively checks for entity reference cycles.
- fn check_entity_recursion(
- &self,
- value: &str,
- visited: &mut std::collections::HashSet<String>,
- safe: &mut std::collections::HashSet<String>,
- ) -> Result<(), ParseError> {
- for ref_name in Self::extract_entity_refs(value) {
- if safe.contains(ref_name) {
+ /// Checks the declaration graph without using the native call stack.
+ fn check_entity_recursion<'b>(
+ &'b self,
+ value: &'b str,
+ visited: &mut S::Names<&'b str, ()>,
+ safe: &mut S::Names<&'b str, ()>,
+ ) -> Result<(), DtdError<S>> {
+ // XML 1.0 §4.1 WFC No Recursion forbids direct and indirect cycles,
+ // not deep acyclic graphs. Keep DFS continuation state on the heap.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#norecursion
+ let mut frames = self.storage.values(0)?;
+ self.storage.charge(value.len())?;
+ Values::push(&mut frames, (None, Self::extract_entity_refs(value)))?;
+ while let Some((_, references)) = frames.last_mut() {
+ let Some(ref_name) = references.next() else {
+ if let Some((Some(name), _)) = frames.pop() {
+ visited.remove(name, self.storage)?;
+ Names::insert(safe, name, ())?;
+ }
+ continue;
+ };
+ if safe.get(ref_name, self.storage)?.is_some() {
continue;
}
- if visited.contains(ref_name) {
- return Err(self.fatal(format!("recursive entity reference: '{ref_name}'")));
+ if visited.get(ref_name, self.storage)?.is_some() {
+ return Err(self.fatal(format_args!("recursive entity reference: '{ref_name}'")));
}
- if let Some(decl) = self.dtd.entities.get(ref_name) {
- if let EntityKind::Internal(ref inner_value) = decl.kind {
- visited.insert(ref_name.to_string());
- self.check_entity_recursion(inner_value, visited, safe)?;
- visited.remove(ref_name);
- safe.insert(ref_name.to_string());
- }
+ if let Some(decl) = self.dtd.entities.get(ref_name, self.storage)?
+ && let EntityKind::Internal(ref inner_value) = decl.kind
+ {
+ Names::insert(visited, ref_name, ())?;
+ self.storage.charge(inner_value.len())?;
+ // The cursor borrows retained replacement text; no reference
+ // list or entity-name copy is needed for an edge or a frame.
+ Values::push(
+ &mut frames,
+ (Some(ref_name), Self::extract_entity_refs(inner_value)),
+ )?;
}
}
Ok(())
}
- /// Recursively checks for parameter entity reference cycles.
+ /// Iteratively checks for parameter entity reference cycles.
///
/// Examines the char-ref-expanded replacement text for `%name;` patterns.
- fn check_pe_recursion(
- &self,
+ fn check_pe_recursion<'b>(
+ &'b self,
value: &str,
- visited: &mut std::collections::HashSet<String>,
- safe: &mut std::collections::HashSet<String>,
- ) -> Result<(), ParseError> {
- for ref_name in Self::extract_pe_refs(value) {
- if safe.contains(&ref_name) {
+ visited: &mut S::Names<&'b str, ()>,
+ safe: &mut S::Names<&'b str, ()>,
+ ) -> Result<(), DtdError<S>> {
+ // The same No Recursion rule applies to PEs. Decoded frame text owns
+ // its storage, while active/completed names borrow the declaration map.
+ let mut frames = self.storage.values(0)?;
+ self.storage.charge(value.len())?;
+ Values::push(&mut frames, (None, ReferenceText::<S>::Borrowed(value), 0))?;
+ while let Some((_, text, cursor)) = frames.last_mut() {
+ let Some(ref_name) = Self::next_pe_ref(text.as_ref(), cursor) else {
+ if let Some((Some(name), _, _)) = frames.pop() {
+ visited.remove(name, self.storage)?;
+ Names::insert(safe, name, ())?;
+ }
+ continue;
+ };
+ if safe.get(ref_name, self.storage)?.is_some() {
continue;
}
- if visited.contains(&ref_name) {
- return Err(self.fatal(format!(
+ if visited.get(ref_name, self.storage)?.is_some() {
+ return Err(self.fatal(format_args!(
"recursive parameter entity reference: '%{ref_name}'"
)));
}
- if let Some(decl) = self.dtd.param_entities.get(&ref_name) {
- if let EntityKind::Internal(ref inner_value) = decl.kind {
- let expanded = expand_char_refs_only(inner_value);
- visited.insert(ref_name.clone());
- self.check_pe_recursion(&expanded, visited, safe)?;
- visited.remove(&ref_name);
- safe.insert(ref_name.clone());
- }
+ if let Some((name, decl)) = self.dtd.param_entities.key_value(ref_name, self.storage)?
+ && let EntityKind::Internal(ref inner_value) = decl.kind
+ {
+ let expanded = expand_char_refs_only(inner_value, self.storage)?;
+ self.storage.charge(expanded.len())?;
+ Names::insert(visited, name.as_ref(), ())?;
+ Values::push(
+ &mut frames,
+ (
+ Some(name.as_ref()),
+ ReferenceText::<S>::Decoded(expanded),
+ 0,
+ ),
+ )?;
}
}
Ok(())
}
- /// Extracts parameter entity reference names (`%name;`) from a string.
- fn extract_pe_refs(value: &str) -> Vec<String> {
- let mut refs = Vec::new();
+ /// Scans the next PE edge without allocating an edge list or copying names.
+ fn next_pe_ref<'b>(value: &'b str, cursor: &mut usize) -> Option<&'b str> {
let bytes = value.as_bytes();
- let mut i = 0;
+ let mut i = *cursor;
while i < bytes.len() {
if bytes[i] == b'%' {
i += 1;
@@ -969,62 +1325,73 @@
i += 1;
}
if i < bytes.len() && bytes[i] == b';' && i > start {
- if let Ok(name) = std::str::from_utf8(&bytes[start..i]) {
- refs.push(name.to_string());
- }
+ let end = i;
i += 1;
+ *cursor = i;
+ return Some(&value[start..end]);
}
}
} else {
i += 1;
}
}
- refs
+ *cursor = i;
+ None
}
/// Validates entity references in attribute default values.
///
/// Checks WFC: No External Entity References (§3.1) and
/// WFC: No `<` in Attribute Values for entity replacement text.
- fn validate_attr_default_entities(
- &self,
- value: &str,
- checked: &mut std::collections::HashSet<String>,
- ) -> Result<(), ParseError> {
- for ref_name in Self::extract_entity_refs(value) {
+ fn validate_attr_default_entities<'b>(
+ &'b self,
+ value: &'b str,
+ checked: &mut S::Names<&'b str, ()>,
+ ) -> Result<(), DtdError<S>> {
+ let mut frames = self.storage.values(0)?;
+ self.storage.charge(value.len())?;
+ Values::push(&mut frames, Self::extract_entity_refs(value))?;
+ while let Some(references) = frames.last_mut() {
+ let Some(ref_name) = references.next() else {
+ frames.pop();
+ continue;
+ };
// Built-in entities are always fine
if matches!(ref_name, "amp" | "lt" | "gt" | "apos" | "quot") {
continue;
}
- if checked.contains(ref_name) {
+ if checked.get(ref_name, self.storage)?.is_some() {
continue;
}
- match self.dtd.entities.get(ref_name) {
+ match self.dtd.entities.get(ref_name, self.storage)? {
None => {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"undeclared entity '{ref_name}' referenced in \
attribute default value"
)));
}
Some(decl) => match &decl.kind {
EntityKind::External { .. } => {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"attribute default value must not reference \
external entity '{ref_name}'"
)));
}
- EntityKind::Internal(ref text) => {
+ EntityKind::Internal(text) => {
// Check for '<' in replacement text (WFC: No < in
// Attribute Values, XML 1.0 §3.1)
+ self.storage.charge(text.len())?;
if text.contains('<') {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"entity '{ref_name}' contains '<' and cannot \
be used in attribute values"
)));
}
- // Recursively check referenced entities
- checked.insert(ref_name.to_string());
- self.validate_attr_default_entities(text, checked)?;
+ // The graph is known acyclic. Memoize before entering
+ // its continuation, without copying the retained name.
+ Names::insert(checked, ref_name, ())?;
+ self.storage.charge(text.len())?;
+ Values::push(&mut frames, Self::extract_entity_refs(text))?;
}
},
}
@@ -1036,65 +1403,34 @@
///
/// Returns an iterator over entity names found in `&name;` patterns,
/// excluding character references (`&#...;`).
- fn extract_entity_refs(value: &str) -> Vec<&str> {
- let mut refs = Vec::new();
- let bytes = value.as_bytes();
- let mut i = 0;
- while i < bytes.len() {
- if bytes[i] == b'&' {
- i += 1;
- if i < bytes.len() && bytes[i] == b'#' {
- // Character reference — skip
- while i < bytes.len() && bytes[i] != b';' {
- i += 1;
- }
- if i < bytes.len() {
- i += 1;
- }
- } else {
- // Entity reference
- let start = i;
- while i < bytes.len() && bytes[i] != b';' && bytes[i] != b'&' {
- i += 1;
- }
- if i < bytes.len() && bytes[i] == b';' && i > start {
- if let Ok(name) = std::str::from_utf8(&bytes[start..i]) {
- refs.push(name);
- }
- i += 1;
- }
- }
- } else {
- i += 1;
- }
- }
- refs
+ fn extract_entity_refs(value: &str) -> EntityReferences<'_> {
+ EntityReferences { value, cursor: 0 }
}
// --- ELEMENT declaration ---
// See XML 1.0 §3.2: [45] elementdecl
- fn parse_element_decl(&mut self) -> Result<(), ParseError> {
+ fn parse_element_decl(&mut self) -> Result<(), DtdError<S>> {
self.expect_str(b"<!ELEMENT")?;
self.skip_whitespace_required()?;
let name = self.parse_name()?;
self.skip_whitespace_required()?;
let content_model = self.parse_content_model()?;
- self.skip_whitespace();
+ self.skip_whitespace()?;
self.expect_byte(b'>')?;
let decl = ElementDecl {
- name: name.clone(),
+ name: self.storage.text(&name)?,
content_model,
};
self.dtd
.declarations
- .push(DtdDeclaration::Element(decl.clone()));
- self.dtd.elements.insert(name, decl);
+ .push(DtdDeclaration::Element(decl.copy_in(self.storage)?))?;
+ Names::insert(&mut self.dtd.elements, name, decl)?;
Ok(())
}
- fn parse_content_model(&mut self) -> Result<ContentModel, ParseError> {
+ fn parse_content_model(&mut self) -> Result<ContentModel<S>, DtdError<S>> {
if self.looking_at(b"EMPTY") {
self.expect_str(b"EMPTY")?;
return Ok(ContentModel::Empty);
@@ -1106,32 +1442,32 @@
// Must be Mixed or Children, both start with '('
self.expect_byte(b'(')?;
- self.skip_whitespace();
+ self.skip_whitespace()?;
// Check for mixed content: (#PCDATA ...)
if self.looking_at(b"#PCDATA") {
self.expect_str(b"#PCDATA")?;
- self.skip_whitespace();
+ self.skip_whitespace()?;
- let mut names = Vec::new();
+ let mut names = self.storage.values(0)?;
if self.peek() == Some(b')') {
// (#PCDATA) — text only
- self.advance(1);
+ self.advance(1)?;
// Optional '*' after (#PCDATA) — some DTDs write (#PCDATA)*
if self.peek() == Some(b'*') {
- self.advance(1);
+ self.advance(1)?;
}
return Ok(ContentModel::Mixed(names));
}
// (#PCDATA|a|b)*
while self.peek() == Some(b'|') {
- self.advance(1);
- self.skip_whitespace();
+ self.advance(1)?;
+ self.skip_whitespace()?;
let elem_name = self.parse_name()?;
- names.push(elem_name);
- self.skip_whitespace();
+ names.push(elem_name)?;
+ self.skip_whitespace()?;
}
self.expect_byte(b')')?;
@@ -1142,137 +1478,141 @@
// Element-only content: parse as a content spec group. The opening
// '(' has already been consumed, so we are one level deep.
- let spec = self.parse_content_spec_group(1)?;
+ let spec = self.parse_content_groups()?;
Ok(ContentModel::Children(spec))
}
- /// Parses a content spec starting after the opening '(' has been consumed
- /// and the first item is NOT `#PCDATA`.
- ///
- /// `depth` is the current parenthesis-nesting level (1 for the outermost
- /// group). It bounds the mutual recursion with `parse_content_particle` so
- /// that untrusted input cannot exhaust the stack; see
- /// [`MAX_CONTENT_MODEL_DEPTH`].
- fn parse_content_spec_group(&mut self, depth: u32) -> Result<ContentSpec, ParseError> {
- if depth > MAX_CONTENT_MODEL_DEPTH {
- return Err(self.fatal(format!(
- "content model nesting exceeds maximum depth of {MAX_CONTENT_MODEL_DEPTH}"
- )));
- }
- let mut first = self.parse_content_particle(depth)?;
- self.skip_whitespace();
-
- // Determine if this is a sequence (,) or choice (|)
- if self.peek() == Some(b',') {
- // Sequence
- let mut items = vec![first];
- while self.peek() == Some(b',') {
- self.advance(1);
- self.skip_whitespace();
- let item = self.parse_content_particle(depth)?;
- items.push(item);
- self.skip_whitespace();
- }
- self.expect_byte(b')')?;
- let occurrence = self.parse_occurrence();
- Ok(ContentSpec {
- kind: ContentSpecKind::Seq(items),
- occurrence,
- })
- } else if self.peek() == Some(b'|') {
- // Choice
- let mut items = vec![first];
- while self.peek() == Some(b'|') {
- self.advance(1);
- self.skip_whitespace();
- let item = self.parse_content_particle(depth)?;
- items.push(item);
- self.skip_whitespace();
+ fn parse_content_groups(&mut self) -> Result<ContentSpec<S>, DtdError<S>> {
+ struct Group<S: Storage> {
+ items: S::Values<ContentSpec<S>>,
+ separator: Option<u8>,
+ need_particle: bool,
+ }
+ let mut groups: S::Values<Group<S>> = self.storage.values(0)?;
+ groups.push(Group {
+ items: self.storage.values(0)?,
+ separator: None,
+ need_particle: true,
+ })?;
+ loop {
+ self.storage.charge(1)?;
+ if groups.last().expect("outer group exists").need_particle {
+ if self.peek() == Some(b'(') {
+ if groups.len() == MAX_CONTENT_MODEL_DEPTH as usize {
+ return Err(self.fatal(format_args!("content model nesting exceeds maximum depth of {MAX_CONTENT_MODEL_DEPTH}")));
+ }
+ self.advance(1)?;
+ self.skip_whitespace()?;
+ groups.push(Group {
+ items: self.storage.values(0)?,
+ separator: None,
+ need_particle: true,
+ })?;
+ continue;
+ }
+ let name = self.parse_name()?;
+ let occurrence = self.parse_occurrence()?;
+ let group = groups.last_mut().expect("outer group exists");
+ group.items.push(ContentSpec {
+ kind: ContentSpecKind::Name(name),
+ occurrence,
+ })?;
+ group.need_particle = false;
+ }
+ self.skip_whitespace()?;
+ if let Some(separator @ (b',' | b'|')) = self.peek() {
+ let group = groups.last_mut().expect("outer group exists");
+ if let Some(expected) = group.separator {
+ if expected != separator {
+ return Err(
+ self.fatal("content-model group mixes sequence and choice separators")
+ );
+ }
+ } else {
+ group.separator = Some(separator);
+ }
+ group.need_particle = true;
+ self.advance(1)?;
+ self.skip_whitespace()?;
+ continue;
}
self.expect_byte(b')')?;
- let occurrence = self.parse_occurrence();
- Ok(ContentSpec {
- kind: ContentSpecKind::Choice(items),
- occurrence,
- })
- } else {
- // Single item group: (item)?/*
- self.expect_byte(b')')?;
- let group_occurrence = self.parse_occurrence();
-
- if group_occurrence != Occurrence::Once {
- // Group has occurrence: (X)+ → wrap in Seq
- Ok(ContentSpec {
- kind: ContentSpecKind::Seq(vec![first]),
- occurrence: group_occurrence,
- })
- } else if first.occurrence != Occurrence::Once {
- // Inner particle has occurrence but group doesn't.
- // libxml2 normalizes (X+) → (X)+ by moving occurrence
- // to the outer group.
- let inner_occ = first.occurrence;
- first.occurrence = Occurrence::Once;
- Ok(ContentSpec {
- kind: ContentSpecKind::Seq(vec![first]),
- occurrence: inner_occ,
- })
- } else {
- // No occurrence on either — unwrap the group
- Ok(first)
- }
- }
- }
-
- /// Parses a single content particle: a name with an optional occurrence
- /// indicator, or a nested parenthesised group.
- ///
- /// `depth` is the enclosing parenthesis-nesting level; a nested group
- /// recurses at `depth + 1`. See [`MAX_CONTENT_MODEL_DEPTH`].
- fn parse_content_particle(&mut self, depth: u32) -> Result<ContentSpec, ParseError> {
- if self.peek() == Some(b'(') {
- self.advance(1);
- self.skip_whitespace();
- self.parse_content_spec_group(depth + 1)
- } else {
- let name = self.parse_name()?;
- let occurrence = self.parse_occurrence();
- Ok(ContentSpec {
- kind: ContentSpecKind::Name(name),
- occurrence,
- })
+ let occurrence = self.parse_occurrence()?;
+ let mut group = groups.pop().expect("outer group exists");
+ let spec = match group.separator {
+ Some(b',') => ContentSpec {
+ kind: ContentSpecKind::Seq(group.items),
+ occurrence,
+ },
+ Some(b'|') => ContentSpec {
+ kind: ContentSpecKind::Choice(group.items),
+ occurrence,
+ },
+ Some(_) => unreachable!("only XML content-model separators are stored"),
+ None => {
+ let mut item = group
+ .items
+ .pop()
+ .expect("a group contains a parsed particle");
+ if occurrence == Occurrence::Once && item.occurrence == Occurrence::Once {
+ item
+ } else {
+ // Preserve the donor's single-particle normalization:
+ // (X+) becomes (X)+; an explicit outer occurrence is
+ // not merged with the inner particle's occurrence.
+ let outer = if occurrence == Occurrence::Once {
+ let inner = item.occurrence;
+ item.occurrence = Occurrence::Once;
+ inner
+ } else {
+ occurrence
+ };
+ group.items.push(item)?;
+ ContentSpec {
+ kind: ContentSpecKind::Seq(group.items),
+ occurrence: outer,
+ }
+ }
+ }
+ };
+ let Some(parent) = groups.last_mut() else {
+ return Ok(spec);
+ };
+ parent.items.push(spec)?;
+ parent.need_particle = false;
}
}
- fn parse_occurrence(&mut self) -> Occurrence {
- match self.peek() {
+ fn parse_occurrence(&mut self) -> Result<Occurrence, DtdError<S>> {
+ Ok(match self.peek() {
Some(b'?') => {
- self.advance(1);
+ self.advance(1)?;
Occurrence::Optional
}
Some(b'*') => {
- self.advance(1);
+ self.advance(1)?;
Occurrence::ZeroOrMore
}
Some(b'+') => {
- self.advance(1);
+ self.advance(1)?;
Occurrence::OneOrMore
}
_ => Occurrence::Once,
- }
+ })
}
// --- ATTLIST declaration ---
// See XML 1.0 §3.3: [52] AttlistDecl
- fn parse_attlist_decl(&mut self) -> Result<(), ParseError> {
+ fn parse_attlist_decl(&mut self) -> Result<(), DtdError<S>> {
self.expect_str(b"<!ATTLIST")?;
self.skip_whitespace_required()?;
let element_name = self.parse_name()?;
loop {
- self.skip_whitespace();
+ self.skip_whitespace()?;
if self.peek() == Some(b'>') {
- self.advance(1);
+ self.advance(1)?;
break;
}
@@ -1283,7 +1623,7 @@
let default = self.parse_attribute_default()?;
let decl = AttributeDecl {
- element_name: element_name.clone(),
+ element_name: self.storage.text(&element_name)?,
attribute_name,
attribute_type,
default,
@@ -1291,22 +1631,44 @@
// Per XML 1.0 §3.3, the first attribute declaration is binding;
// subsequent declarations for the same attribute are ignored.
- let attrs = self.dtd.attributes.entry(element_name.clone()).or_default();
- if !attrs
- .iter()
- .any(|a| a.attribute_name == decl.attribute_name)
+ if self
+ .dtd
+ .attributes
+ .get(&element_name, self.storage)?
+ .is_none()
{
+ Names::insert(
+ &mut self.dtd.attributes,
+ self.storage.text(&element_name)?,
+ self.storage.values(0)?,
+ )?;
+ }
+ let attrs = self
+ .dtd
+ .attributes
+ .get_mut(&element_name, self.storage)?
+ .expect("attribute group inserted above");
+ self.storage.charge(attrs.len())?;
+ let mut declared = false;
+ for attribute in &**attrs {
+ self.storage.charge(attribute.attribute_name.len())?;
+ if attribute.attribute_name.as_ref() == decl.attribute_name.as_ref() {
+ declared = true;
+ break;
+ }
+ }
+ if !declared {
self.dtd
.declarations
- .push(DtdDeclaration::Attlist(decl.clone()));
- attrs.push(decl);
+ .push(DtdDeclaration::Attlist(decl.copy_in(self.storage)?))?;
+ attrs.push(decl)?;
}
}
Ok(())
}
- fn parse_attribute_type(&mut self) -> Result<AttributeType, ParseError> {
+ fn parse_attribute_type(&mut self) -> Result<AttributeType<S>, DtdError<S>> {
if self.looking_at(b"CDATA") {
self.expect_str(b"CDATA")?;
Ok(AttributeType::CData)
@@ -1344,30 +1706,30 @@
}
}
- fn parse_enumerated_values(&mut self) -> Result<Vec<String>, ParseError> {
+ fn parse_enumerated_values(&mut self) -> Result<S::Values<S::Text>, DtdError<S>> {
self.expect_byte(b'(')?;
- self.skip_whitespace();
- let mut values = Vec::new();
+ self.skip_whitespace()?;
+ let mut values = self.storage.values(0)?;
let first = self.parse_nmtoken()?;
- values.push(first);
+ values.push(first)?;
loop {
- self.skip_whitespace();
+ self.skip_whitespace()?;
if self.peek() == Some(b')') {
- self.advance(1);
+ self.advance(1)?;
break;
}
self.expect_byte(b'|')?;
- self.skip_whitespace();
+ self.skip_whitespace()?;
let val = self.parse_nmtoken()?;
- values.push(val);
+ values.push(val)?;
}
Ok(values)
}
- fn parse_attribute_default(&mut self) -> Result<AttributeDefault, ParseError> {
+ fn parse_attribute_default(&mut self) -> Result<AttributeDefault<S>, DtdError<S>> {
if self.looking_at(b"#REQUIRED") {
self.expect_str(b"#REQUIRED")?;
Ok(AttributeDefault::Required)
@@ -1391,18 +1753,20 @@
// See XML 1.0 §4.2: [70] EntityDecl
#[allow(clippy::too_many_lines)]
- fn parse_entity_decl(&mut self) -> Result<(), ParseError> {
+ fn parse_entity_decl(&mut self) -> Result<(), DtdError<S>> {
self.expect_str(b"<!ENTITY")?;
self.skip_whitespace_required()?;
// Parameter entities (% name)
if self.peek() == Some(b'%') {
- self.advance(1);
+ self.advance(1)?;
self.skip_whitespace_required()?;
let pe_name = self.parse_name()?;
// Namespaces in XML 1.0: entity names must be NCNames (no colons).
if pe_name.contains(':') {
- return Err(self.fatal(format!("entity name '{pe_name}' must not contain a colon")));
+ return Err(self.fatal(format_args!(
+ "entity name '{pe_name}' must not contain a colon"
+ )));
}
self.skip_whitespace_required()?;
@@ -1435,7 +1799,7 @@
return Err(self.fatal("expected entity value or external ID"));
};
- self.skip_whitespace();
+ self.skip_whitespace()?;
// Reject NDATA on parameter entities (XML 1.0 §4.2.2)
if self.looking_at(b"NDATA") {
return Err(self.fatal("NDATA annotation is not allowed on parameter entities"));
@@ -1443,14 +1807,21 @@
self.expect_byte(b'>')?;
// Store PE declaration (first declaration wins per XML 1.0 §4.2)
- if let Some(kind) = pe_kind {
- self.dtd
+ if let Some(kind) = pe_kind
+ && self
+ .dtd
.param_entities
- .entry(pe_name)
- .or_insert(EntityDecl {
- name: String::new(),
+ .get(&pe_name, self.storage)?
+ .is_none()
+ {
+ Names::insert(
+ &mut self.dtd.param_entities,
+ pe_name,
+ EntityDecl {
+ name: self.storage.text("")?,
kind,
- });
+ },
+ )?;
}
return Ok(());
}
@@ -1458,7 +1829,9 @@
let name = self.parse_name()?;
// Namespaces in XML 1.0: entity names must be NCNames (no colons).
if name.contains(':') {
- return Err(self.fatal(format!("entity name '{name}' must not contain a colon")));
+ return Err(self.fatal(format_args!(
+ "entity name '{name}' must not contain a colon"
+ )));
}
self.skip_whitespace_required()?;
@@ -1491,7 +1864,7 @@
return Err(self.fatal("expected entity value or external ID"));
};
- let had_ws = self.skip_whitespace();
+ let had_ws = self.skip_whitespace()?;
// Handle optional NDATA for unparsed external entities (XML 1.0 §4.2.2)
if self.looking_at(b"NDATA") {
@@ -1506,7 +1879,7 @@
self.expect_str(b"NDATA")?;
self.skip_whitespace_required()?;
let _notation_name = self.parse_name()?;
- self.skip_whitespace();
+ self.skip_whitespace()?;
}
self.expect_byte(b'>')?;
@@ -1514,26 +1887,30 @@
// Per XML 1.0 §4.2, the first entity declaration is binding;
// subsequent declarations of the same entity are ignored.
let decl = EntityDecl {
- name: name.clone(),
+ name: self.storage.text(&name)?,
kind,
};
self.dtd
.declarations
- .push(DtdDeclaration::Entity(decl.clone()));
- self.dtd.entities.entry(name).or_insert(decl);
+ .push(DtdDeclaration::Entity(decl.copy_in(self.storage)?))?;
+ if self.dtd.entities.get(&name, self.storage)?.is_none() {
+ Names::insert(&mut self.dtd.entities, name, decl)?;
+ }
Ok(())
}
// --- NOTATION declaration ---
// See XML 1.0 §4.7: [82] NotationDecl
- fn parse_notation_decl(&mut self) -> Result<(), ParseError> {
+ fn parse_notation_decl(&mut self) -> Result<(), DtdError<S>> {
self.expect_str(b"<!NOTATION")?;
self.skip_whitespace_required()?;
let name = self.parse_name()?;
// Namespaces in XML 1.0: notation names must be NCNames (no colons).
if name.contains(':') {
- return Err(self.fatal(format!("notation name '{name}' must not contain a colon")));
+ return Err(self.fatal(format_args!(
+ "notation name '{name}' must not contain a colon"
+ )));
}
self.skip_whitespace_required()?;
@@ -1548,7 +1925,7 @@
let pid = self.parse_quoted_value()?;
self.validate_public_id(&pid)?;
// System ID is optional for notations with PUBLIC
- self.skip_whitespace();
+ self.skip_whitespace()?;
let sid = if self.peek() == Some(b'"') || self.peek() == Some(b'\'') {
Some(self.parse_quoted_value()?)
} else {
@@ -1559,45 +1936,47 @@
return Err(self.fatal("expected SYSTEM or PUBLIC in NOTATION declaration"));
};
- self.skip_whitespace();
+ self.skip_whitespace()?;
self.expect_byte(b'>')?;
let decl = NotationDecl {
- name: name.clone(),
+ name: self.storage.text(&name)?,
system_id,
public_id,
};
self.dtd
.declarations
- .push(DtdDeclaration::Notation(decl.clone()));
- self.dtd.notations.insert(name, decl);
+ .push(DtdDeclaration::Notation(decl.copy_in(self.storage)?))?;
+ Names::insert(&mut self.dtd.notations, name, decl)?;
Ok(())
}
// --- Skip helpers ---
/// Parses a comment and stores it as a `DtdDeclaration::Comment`.
- fn parse_comment_decl(&mut self) -> Result<(), ParseError> {
+ fn parse_comment_decl(&mut self) -> Result<(), DtdError<S>> {
self.expect_str(b"<!--")?;
- let start = self.pos;
+ let start = self.input.pos();
loop {
if self.at_end() {
return Err(self.fatal("unexpected end of input in comment"));
}
if self.looking_at(b"-->") {
- let text = std::str::from_utf8(&self.input[start..self.pos])
- .unwrap_or("")
- .to_string();
- self.advance(3);
- self.dtd.declarations.push(DtdDeclaration::Comment(text));
+ self.storage.charge(self.input.pos() - start)?;
+ let text = self.storage.text(
+ std::str::from_utf8(self.input.slice(start, self.input.pos()))
+ .expect("comment ends at an ASCII UTF-8 boundary"),
+ )?;
+ self.advance(3)?;
+ self.dtd.declarations.push(DtdDeclaration::Comment(text))?;
return Ok(());
}
- self.advance(1);
+ self.advance(1)?;
}
}
/// Parses a processing instruction and stores it as a `DtdDeclaration::Pi`.
- fn parse_pi_decl(&mut self) -> Result<(), ParseError> {
+ fn parse_pi_decl(&mut self) -> Result<(), DtdError<S>> {
self.expect_str(b"<?")?;
// Parse and validate the PI target name (XML 1.0 §2.6)
@@ -1610,8 +1989,10 @@
// If we're immediately at ?>, no data — that's fine
if self.looking_at(b"?>") {
- self.advance(2);
- self.dtd.declarations.push(DtdDeclaration::Pi(target, None));
+ self.advance(2)?;
+ self.dtd
+ .declarations
+ .push(DtdDeclaration::Pi(target, None))?;
return Ok(());
}
@@ -1623,26 +2004,31 @@
return Err(self.fatal("space required between PI target and data"));
}
- let start = self.pos;
+ let start = self.input.pos();
loop {
if self.at_end() {
return Err(self.fatal("unexpected end of input in processing instruction"));
}
if self.looking_at(b"?>") {
- let data = std::str::from_utf8(&self.input[start..self.pos])
- .unwrap_or("")
- .trim()
- .to_string();
- self.advance(2);
+ self.storage.charge(self.input.pos() - start)?;
+ self.storage.charge(self.input.pos() - start)?;
+ let data = self.storage.text(
+ std::str::from_utf8(self.input.slice(start, self.input.pos()))
+ .expect("PI ends at an ASCII UTF-8 boundary")
+ .trim(),
+ )?;
+ self.advance(2)?;
let data = if data.is_empty() { None } else { Some(data) };
- self.dtd.declarations.push(DtdDeclaration::Pi(target, data));
+ self.dtd
+ .declarations
+ .push(DtdDeclaration::Pi(target, data))?;
return Ok(());
}
- self.advance(1);
+ self.advance(1)?;
}
}
- fn skip_pe_reference(&mut self) -> Result<(), ParseError> {
+ fn skip_pe_reference(&mut self) -> Result<(), DtdError<S>> {
self.expect_byte(b'%')?;
// Read the name
let _name = self.parse_name()?;
@@ -1652,94 +2038,12 @@
// --- Name / token parsing ---
- fn parse_name(&mut self) -> Result<String, ParseError> {
- if self.pos >= self.input.len() {
- return Err(self.fatal("expected name, found end of input"));
- }
-
- let start = self.pos;
- let first = self.input[self.pos];
-
- // ASCII fast path
- if is_ascii_name_start(first) {
- self.pos += 1;
- self.column += 1;
- while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) {
- self.pos += 1;
- self.column += 1;
- }
- if self.pos >= self.input.len() || self.input[self.pos] < 0x80 {
- let name = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- return Ok(name.to_string());
- }
- // Fall through to slow path for non-ASCII continuation
- } else {
- let ch = self
- .peek_char()
- .ok_or_else(|| self.fatal("expected name"))?;
- if !is_name_start_char(ch) {
- return Err(self.fatal(format!("invalid name start character: '{ch}'")));
- }
- self.advance_char(ch);
- }
-
- while let Some(ch) = self.peek_char() {
- if is_name_char(ch) {
- self.advance_char(ch);
- } else {
- break;
- }
- }
-
- let name = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in name"))?;
- Ok(name.to_string())
+ fn parse_name(&mut self) -> Result<S::Text, DtdError<S>> {
+ Ok(self.storage.text(self.input.parse_name()?)?)
}
- fn parse_nmtoken(&mut self) -> Result<String, ParseError> {
- if self.pos >= self.input.len() {
- return Err(self.fatal("expected NMTOKEN, found end of input"));
- }
-
- let start = self.pos;
- let first = self.input[self.pos];
-
- // ASCII fast path
- if is_ascii_name_char(first) {
- self.pos += 1;
- self.column += 1;
- while self.pos < self.input.len() && is_ascii_name_char(self.input[self.pos]) {
- self.pos += 1;
- self.column += 1;
- }
- if self.pos >= self.input.len() || self.input[self.pos] < 0x80 {
- let token = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in NMTOKEN"))?;
- return Ok(token.to_string());
- }
- // Fall through to slow path
- } else {
- let ch = self
- .peek_char()
- .ok_or_else(|| self.fatal("expected NMTOKEN"))?;
- if !is_name_char(ch) {
- return Err(self.fatal(format!("invalid NMTOKEN character: '{ch}'")));
- }
- self.advance_char(ch);
- }
-
- while let Some(ch) = self.peek_char() {
- if is_name_char(ch) {
- self.advance_char(ch);
- } else {
- break;
- }
- }
-
- let token = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in NMTOKEN"))?;
- Ok(token.to_string())
+ fn parse_nmtoken(&mut self) -> Result<S::Text, DtdError<S>> {
+ Ok(self.storage.text(self.input.parse_nmtoken()?)?)
}
/// Validates an entity value per XML 1.0 §4.3.2 `EntityValue` production.
@@ -1751,11 +2055,15 @@
&self,
value: &str,
is_parameter_entity: bool,
- ) -> Result<(), ParseError> {
- // First validate all characters are valid XML chars.
+ ) -> Result<(), DtdError<S>> {
+ // Character validation, reference scanning, numeric conversion and
+ // referenced-name validation are separate passes over known text.
+ for _ in 0..4 {
+ self.storage.charge(value.len())?;
+ }
for c in value.chars() {
- if !crate::parser::input::is_xml_char(c) {
- return Err(self.fatal(format!(
+ if !crate::parser_xmloxide::parser::input::is_xml_char(c) {
+ return Err(self.fatal(format_args!(
"invalid XML character U+{:04X} in entity value",
c as u32
)));
@@ -1767,7 +2075,7 @@
// of external parsed entities.
if value.starts_with("<?xml") {
let after = value.as_bytes().get(5).copied();
- if after.map_or(true, |b| b == b' ' || b == b'\t' || b == b'?') {
+ if after.is_none_or(|b| b == b' ' || b == b'\t' || b == b'?') {
return Err(self.fatal("text declaration is not allowed in internal entity value"));
}
}
@@ -1819,13 +2127,13 @@
i += 1;
// Validate the referenced character is a valid XML char
if let Some(c) = char::from_u32(char_val) {
- if !crate::parser::input::is_xml_char(c) {
- return Err(self.fatal(format!(
+ if !crate::parser_xmloxide::parser::input::is_xml_char(c) {
+ return Err(self.fatal(format_args!(
"character reference &#x{char_val:X}; refers to invalid XML character"
)));
}
} else {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"character reference value {char_val} is not a valid Unicode code point"
)));
}
@@ -1844,12 +2152,12 @@
}
// Validate the entity name starts with a NameStartChar
let name_str = std::str::from_utf8(&bytes[start..i]).unwrap_or("");
- if let Some(first_char) = name_str.chars().next() {
- if !is_name_start_char(first_char) {
- return Err(self.fatal(format!(
- "entity reference name must start with a letter or underscore, found '{first_char}'"
- )));
- }
+ if let Some(first_char) = name_str.chars().next()
+ && !is_name_start_char(first_char)
+ {
+ return Err(self.fatal(format_args!(
+ "entity reference name must start with a letter or underscore, found '{first_char}'"
+ )));
}
i += 1;
}
@@ -1886,7 +2194,9 @@
/// Checks that entity references within the default value refer to
/// entities that have already been declared (WFC: Entity Declared).
/// Also rejects `<` in default values (WFC: No `<` in Attribute Values).
- fn validate_default_value(&self, value: &str) -> Result<(), ParseError> {
+ fn validate_default_value(&self, value: &str) -> Result<(), DtdError<S>> {
+ self.storage.charge(value.len())?;
+ self.storage.charge(value.len())?;
let bytes = value.as_bytes();
let mut i = 0;
while i < bytes.len() {
@@ -1915,8 +2225,8 @@
let name = std::str::from_utf8(&bytes[start..i]).unwrap_or("");
// Built-in entities are always available
let is_builtin = matches!(name, "amp" | "lt" | "gt" | "apos" | "quot");
- if !is_builtin && !self.dtd.entities.contains_key(name) {
- return Err(self.fatal(format!(
+ if !is_builtin && self.dtd.entities.get(name, self.storage)?.is_none() {
+ return Err(self.fatal(format_args!(
"undeclared entity '{name}' in attribute default value"
)));
}
@@ -1936,7 +2246,8 @@
/// Validates that a public ID string contains only valid `PubidChar`s
/// per XML 1.0 §2.3 `[13]`.
- fn validate_public_id(&self, pid: &str) -> Result<(), ParseError> {
+ fn validate_public_id(&self, pid: &str) -> Result<(), DtdError<S>> {
+ self.storage.charge(pid.len())?;
for c in pid.chars() {
let valid = matches!(c,
' ' | '\r' | '\n' |
@@ -1945,7 +2256,7 @@
'=' | '?' | ';' | '!' | '*' | '#' | '@' | '$' | '_' | '%'
);
if !valid {
- return Err(self.fatal(format!(
+ return Err(self.fatal(format_args!(
"invalid character in public ID: U+{:04X}",
c as u32
)));
@@ -1954,169 +2265,144 @@
Ok(())
}
- fn parse_quoted_value(&mut self) -> Result<String, ParseError> {
- let quote = self.next_byte()?;
- if quote != b'"' && quote != b'\'' {
- return Err(self.fatal("expected quoted value"));
- }
- let start = self.pos;
- while !self.at_end() && self.peek() != Some(quote) {
- self.advance(1);
- }
- let value = std::str::from_utf8(&self.input[start..self.pos])
- .map_err(|_| self.fatal("invalid UTF-8 in quoted value"))?
- .to_string();
- if self.at_end() {
- return Err(self.fatal("unexpected end of input in quoted value"));
- }
- self.advance(1); // consume closing quote
- Ok(value)
+ fn parse_quoted_value(&mut self) -> Result<S::Text, DtdError<S>> {
+ Ok(self.storage.text(self.input.parse_quoted_value()?)?)
}
// --- Low-level input helpers ---
- fn location(&self) -> SourceLocation {
- SourceLocation {
- line: self.line,
- column: self.column,
- byte_offset: self.pos,
- }
- }
-
fn at_end(&self) -> bool {
- self.pos >= self.input.len()
+ self.input.at_end()
}
fn peek(&self) -> Option<u8> {
- self.input.get(self.pos).copied()
+ self.input.peek()
}
- fn peek_char(&self) -> Option<char> {
- if self.pos >= self.input.len() {
- return None;
- }
- let first = self.input[self.pos];
- // Fast path: ASCII
- if first < 0x80 {
- return Some(first as char);
- }
- // Slow path: multi-byte UTF-8 — decode only the needed bytes
- let len = match first {
- 0xC0..=0xDF => 2,
- 0xE0..=0xEF => 3,
- 0xF0..=0xF7 => 4,
- _ => return None,
- };
- let remaining = &self.input[self.pos..];
- if remaining.len() < len {
- return None;
- }
- std::str::from_utf8(&remaining[..len])
- .ok()
- .and_then(|s| s.chars().next())
+ fn advance(&mut self, count: usize) -> Result<(), DtdError<S>> {
+ self.input.advance(count)
}
- fn advance(&mut self, count: usize) {
- for _ in 0..count {
- if self.pos < self.input.len() {
- if self.input[self.pos] == b'\n' {
- self.line += 1;
- self.column = 1;
- } else {
- self.column += 1;
- }
- self.pos += 1;
- }
- }
+ fn expect_byte(&mut self, expected: u8) -> Result<(), DtdError<S>> {
+ self.input.expect_byte(expected)
}
- fn advance_char(&mut self, ch: char) {
- let len = ch.len_utf8();
- if ch == '\n' {
- self.line += 1;
- self.column = 1;
- } else {
- self.column += 1;
- }
- self.pos += len;
+ fn expect_str(&mut self, expected: &[u8]) -> Result<(), DtdError<S>> {
+ self.input.expect_str(expected)
}
- fn next_byte(&mut self) -> Result<u8, ParseError> {
- if self.at_end() {
- return Err(self.fatal("unexpected end of input"));
- }
- let b = self.input[self.pos];
- self.advance(1);
- Ok(b)
+ fn looking_at(&self, s: &[u8]) -> bool {
+ self.input.looking_at(s)
}
- fn expect_byte(&mut self, expected: u8) -> Result<(), ParseError> {
- let b = self.next_byte()?;
- if b == expected {
- Ok(())
- } else {
- Err(self.fatal(format!(
- "expected '{}', found '{}'",
- expected as char, b as char
- )))
- }
+ fn skip_whitespace(&mut self) -> Result<bool, DtdError<S>> {
+ self.input.skip_whitespace()
}
- fn expect_str(&mut self, expected: &[u8]) -> Result<(), ParseError> {
- for &b in expected {
- self.expect_byte(b)?;
- }
- Ok(())
+ fn skip_whitespace_required(&mut self) -> Result<(), DtdError<S>> {
+ self.input.skip_whitespace_required()
}
- fn looking_at(&self, s: &[u8]) -> bool {
- self.pos + s.len() <= self.input.len() && self.input[self.pos..].starts_with(s)
+ fn fatal(&self, message: impl fmt::Display) -> DtdError<S> {
+ self.input.fatal(message)
}
+}
- fn skip_whitespace(&mut self) -> bool {
- let start = self.pos;
- while let Some(b) = self.peek() {
- if b == b' ' || b == b'\t' || b == b'\r' || b == b'\n' {
- self.advance(1);
- } else {
- break;
- }
- }
- self.pos > start
- }
+// ---------------------------------------------------------------------------
+// Entity value helper functions (used by DTD parser and XML parser)
+// ---------------------------------------------------------------------------
- fn skip_whitespace_required(&mut self) -> Result<(), ParseError> {
- if !self.skip_whitespace() {
- return Err(self.fatal("whitespace required"));
+/// Only PE decoding owns text; the initial frame borrows its existing input.
+enum ReferenceText<'a, S: Storage> {
+ Borrowed(&'a str),
+ Decoded(S::Text),
+}
+
+impl<S: Storage> AsRef<str> for ReferenceText<'_, S> {
+ fn as_ref(&self) -> &str {
+ match self {
+ Self::Borrowed(text) => text,
+ Self::Decoded(text) => text.as_ref(),
}
- Ok(())
}
+}
- fn fatal(&self, message: impl Into<String>) -> ParseError {
- ParseError {
- message: message.into(),
- location: self.location(),
- diagnostics: Vec::new(),
+/// Resumable borrowed references; DFS frames keep a cursor, not a list of edges.
+struct EntityReferences<'a> {
+ value: &'a str,
+ cursor: usize,
+}
+
+impl<'a> Iterator for EntityReferences<'a> {
+ type Item = &'a str;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let bytes = self.value.as_bytes();
+ while self.cursor < bytes.len() {
+ if bytes[self.cursor] != b'&' {
+ self.cursor += 1;
+ continue;
+ }
+ self.cursor += 1;
+ if bytes.get(self.cursor) == Some(&b'#') {
+ while self.cursor < bytes.len() && bytes[self.cursor] != b';' {
+ self.cursor += 1;
+ }
+ if self.cursor < bytes.len() {
+ self.cursor += 1;
+ }
+ continue;
+ }
+ let start = self.cursor;
+ while self.cursor < bytes.len()
+ && bytes[self.cursor] != b';'
+ && bytes[self.cursor] != b'&'
+ {
+ self.cursor += 1;
+ }
+ if bytes.get(self.cursor) == Some(&b';') {
+ let end = self.cursor;
+ self.cursor += 1;
+ if end > start {
+ return Some(&self.value[start..end]);
+ }
+ }
}
+ None
}
}
-// ---------------------------------------------------------------------------
-// Entity value helper functions (used by DTD parser and XML parser)
-// ---------------------------------------------------------------------------
-
/// Expands only character references in a string, leaving entity references
/// as-is. Returns the expanded text.
///
/// Used to compute the replacement text of an internal entity per XML 1.0
/// §4.5: character references are expanded at declaration time, while
/// entity references are left for expansion at reference time.
-pub(crate) fn expand_char_refs_only(value: &str) -> String {
+pub(crate) fn expand_char_refs_only<S: Storage>(
+ value: &str,
+ storage: S,
+) -> Result<S::Text, crate::xml::dom::ParseError> {
+ // XML 1.0 §4.5 expands character references at declaration time, while
+ // retaining general references for the later inclusion step.
+ // https://www.w3.org/TR/2008/REC-xml-20081126/#intern-replacement
+ // Admit scanning, numeric conversion, and source slicing before doing them.
+ storage.charge(value.len())?;
+ storage.charge(value.len())?;
+ storage.charge(value.len())?;
let bytes = value.as_bytes();
- let mut result = String::with_capacity(value.len());
+ let mut result = storage.text("")?;
+ // Expansion cannot grow a validated character-reference spelling. Keeping
+ // this input-sized capacity avoids repeatedly moving the accumulated text.
+ storage.reserve_text(&mut result, value.len())?;
let mut i = 0;
+ let mut literal_start = 0;
while i < bytes.len() {
if bytes[i] == b'&' && i + 1 < bytes.len() && bytes[i + 1] == b'#' {
+ crate::parser_xmloxide::parser::input::append_normalized_line_ends(
+ storage,
+ &mut result,
+ &value[literal_start..i],
+ )?;
i += 2;
let char_val = if i < bytes.len() && bytes[i] == b'x' {
i += 1;
@@ -2138,16 +2424,21 @@
i += 1;
}
if let Some(ch) = char::from_u32(char_val) {
- result.push(ch);
+ storage.append(&mut result, ch.encode_utf8(&mut [0; 4]))?;
}
+ literal_start = i;
} else {
- // Copy one complete UTF-8 character
- let ch = value[i..].chars().next().unwrap_or('\u{FFFD}');
- result.push(ch);
- i += ch.len_utf8();
+ // Only ASCII '&' can split a literal span, so scanning bytes never
+ // splits a UTF-8 character at an eventual append boundary.
+ i += 1;
}
}
- result
+ crate::parser_xmloxide::parser::input::append_normalized_line_ends(
+ storage,
+ &mut result,
+ &value[literal_start..],
+ )?;
+ Ok(result)
}
/// Replaces entity references (`&name;`) with spaces, leaving character
@@ -2250,7 +2541,7 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::Document;
/// use xmloxide::validation::dtd::{parse_dtd, validate};
///
@@ -2311,21 +2602,19 @@
}
});
- if let Some(ref expected_name) = doctype_name {
- if let Some(root_elem) = doc.root_element() {
- if let Some(actual_name) = doc.node_name(root_elem) {
- if actual_name != expected_name {
- errors.push(ValidationError {
- message: format!(
- "root element '{actual_name}' does not match \
- DOCTYPE name '{expected_name}'"
- ),
- line: None,
- column: None,
- });
- }
- }
- }
+ if let Some(ref expected_name) = doctype_name
+ && let Some(root_elem) = doc.root_element()
+ && let Some(actual_name) = doc.node_name(root_elem)
+ && actual_name != expected_name
+ {
+ errors.push(ValidationError {
+ message: format!(
+ "root element '{actual_name}' does not match \
+ DOCTYPE name '{expected_name}'"
+ ),
+ line: None,
+ column: None,
+ });
}
}
@@ -2347,7 +2636,7 @@
// Check if element is declared
let has_element_decls = !dtd.elements.is_empty();
- if has_element_decls && !dtd.elements.contains_key(&elem_name) {
+ if has_element_decls && !dtd.elements.contains_key(elem_name.as_str()) {
errors.push(ValidationError {
message: format!("element '{elem_name}' is not declared in the DTD"),
line: None,
@@ -2356,7 +2645,7 @@
}
// Check content model
- if let Some(elem_decl) = dtd.elements.get(&elem_name) {
+ if let Some(elem_decl) = dtd.elements.get(elem_name.as_str()) {
validate_content_model(doc, node_id, &elem_name, &elem_decl.content_model, errors);
}
@@ -2455,22 +2744,22 @@
ContentModel::Mixed(allowed_names) => {
// Text is always allowed. Check that element children are in the allowed list.
for &child_id in &content_children {
- if let NodeKind::Element { ref name, .. } = doc.node(child_id).kind {
- if !allowed_names.contains(name) {
- errors.push(ValidationError {
- message: format!(
- "element '{name}' is not allowed in mixed content \
- of '{elem_name}' (allowed: #PCDATA{})",
- if allowed_names.is_empty() {
- String::new()
- } else {
- format!("|{}", allowed_names.join("|"))
- }
- ),
- line: None,
- column: None,
- });
- }
+ if let NodeKind::Element { ref name, .. } = doc.node(child_id).kind
+ && !allowed_names.contains(name)
+ {
+ errors.push(ValidationError {
+ message: format!(
+ "element '{name}' is not allowed in mixed content \
+ of '{elem_name}' (allowed: #PCDATA{})",
+ if allowed_names.is_empty() {
+ String::new()
+ } else {
+ format!("|{}", allowed_names.join("|"))
+ }
+ ),
+ line: None,
+ column: None,
+ });
}
}
}
@@ -2930,7 +3219,167 @@
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
- use pretty_assertions::assert_eq;
+
+ #[cfg(feature = "xmldsig")]
+ #[test]
+ fn scoped_dtd_model_and_serialization_share_current_limits() {
+ // DTD model, duplicate declaration records, graph continuations and
+ // serialization must keep current Work and actual live capacity.
+ use crate::xml::dom::storage::Accounted;
+ use structured_asn1::{
+ Encoding, Limits, Parser,
+ memory::{Memory, MemoryAccount, MemoryError},
+ };
+ const SOURCE: &str = "<!ELEMENT root (child,child?)>\n<!ELEMENT child (#PCDATA)>\n<!ATTLIST root id ID #REQUIRED mode (a|b) 'a' fixed CDATA #FIXED 'yes'>\n<!ENTITY leaf 'α	&'>\n<!ENTITY parent '&leaf;'>\n<!ENTITY % parameter 'literal'>\n<!ENTITY external SYSTEM 'urn:external'>\n<!NOTATION external SYSTEM 'urn:external'>\n<!--comment--><?pi data?>";
+ let expected = serialize_dtd(&parse_dtd(SOURCE).unwrap());
+ let run = |capacity, local: usize, aggregate| {
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(capacity, aggregate)));
+ let parser = Parser::new(
+ Encoding::Der,
+ Limits {
+ max_work: local as u64,
+ ..Default::default()
+ },
+ memory.clone(),
+ );
+ let reader = parser.read(&[], 0).unwrap();
+ let work = reader.execution();
+ let storage = Accounted(&work);
+ let result = parse_dtd_in(SOURCE, storage);
+ let result = match result {
+ Ok(dtd) => {
+ assert_eq!(dtd.entities.len(), 3);
+ assert_eq!(dtd.param_entities.len(), 1);
+ assert_eq!(
+ Names::get(&dtd.attributes, "root", storage)
+ .unwrap()
+ .unwrap()
+ .len(),
+ 3
+ );
+ let rendered = serialize_dtd_in(&dtd, storage).map_err(ParseError::Resource);
+ drop(dtd);
+ rendered
+ }
+ Err(error) => Err(error),
+ };
+ let refusal = match result {
+ Ok(rendered) => {
+ assert_eq!(rendered.as_ref(), expected);
+ assert!(memory.used() >= rendered.len());
+ drop(rendered);
+ None
+ }
+ Err(ParseError::Resource(crate::xml::dom::ParseError::SharedExecution(error))) => {
+ Some(error)
+ }
+ Err(error) => panic!("unexpected DTD syntax or allocation error: {error}"),
+ };
+ assert_eq!(
+ memory.used(),
+ 0,
+ "all DTD storage must release on success and refusal"
+ );
+ (refusal, memory.work_used())
+ };
+ let (refusal, needed) = run(1 << 20, 1 << 24, 1 << 24);
+ assert_eq!(refusal, None);
+ assert!(needed > SOURCE.len());
+ assert_eq!(run(1 << 20, 0, 1 << 24).0, Some(MemoryError::WorkExceeded));
+ assert_eq!(run(1 << 20, 1 << 24, 0).0, Some(MemoryError::WorkExceeded));
+ assert_eq!(run(0, 1 << 24, 1 << 24).0, Some(MemoryError::LimitExceeded));
+
+ // Hash-table iteration can change graph memoization order between
+ // independent parses. Exact admission is measured on the SAME retained
+ // model, not guessed from another freshly seeded table's traversal.
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 24)));
+ let owner = Parser::der(memory.clone());
+ let reader = owner.read(&[], 0).unwrap();
+ let producer = reader.execution();
+ let dtd =
+ parse_dtd_in(SOURCE, Accounted(&producer)).unwrap_or_else(|error| panic!("{error}"));
+ let retained = memory.used();
+ let before = memory.work_used();
+ drop(serialize_dtd_in(&dtd, Accounted(&producer)).unwrap());
+ let serialization_work = memory.work_used() - before;
+ assert_eq!(memory.used(), retained);
+ for limit in [0, serialization_work - 1, serialization_work] {
+ let query = Parser::new(
+ Encoding::Der,
+ Limits {
+ max_work: limit as u64,
+ ..Default::default()
+ },
+ memory.clone(),
+ );
+ let cursor = query.read(&[], 0).unwrap();
+ let execution = cursor.execution();
+ let result = serialize_dtd_in(&dtd, Accounted(&execution));
+ if limit == serialization_work {
+ assert_eq!(result.unwrap().as_ref(), expected);
+ } else {
+ assert!(matches!(
+ result,
+ Err(crate::xml::dom::ParseError::SharedExecution(
+ MemoryError::WorkExceeded
+ ))
+ ));
+ }
+ assert_eq!(memory.used(), retained);
+ }
+ drop(dtd);
+ assert_eq!(memory.used(), 0);
+ }
+
+ #[cfg(feature = "xmldsig")]
+ #[test]
+ fn character_reference_storage_uses_the_current_execution() {
+ // The producing account having room must not bypass a local query's
+ // Work ceiling; decoded text keeps its capacity charge until dropped.
+ use crate::xml::dom::storage::Accounted;
+ use structured_asn1::{
+ Encoding, Limits, Parser,
+ memory::{Memory, MemoryAccount, MemoryError},
+ };
+ let source = "π%😀&retained;尾";
+ let expected = "π%😀&retained;尾";
+ assert_eq!(expand_char_refs_only(source, Native).unwrap(), expected);
+
+ let memory = Memory::new(std::sync::Arc::new(MemoryAccount::new(1 << 20, 1 << 24)));
+ let parser = Parser::new(
+ Encoding::Der,
+ Limits {
+ max_work: 0,
+ ..Default::default()
+ },
+ memory.clone(),
+ );
+ let reader = parser.read(&[], 0).unwrap();
+ let work = reader.execution();
+ assert!(matches!(
+ expand_char_refs_only(source, Accounted(&work)),
+ Err(crate::xml::dom::ParseError::SharedExecution(
+ MemoryError::WorkExceeded
+ ))
+ ));
+ assert_eq!(memory.used(), 0);
+ assert_eq!(memory.work_used(), 0);
+
+ let decoded = expand_char_refs_only(source, Accounted(&memory)).unwrap();
+ assert_eq!(&*decoded, expected);
+ assert!(memory.used() >= source.len());
+ drop(decoded);
+ assert_eq!(memory.used(), 0);
+
+ let denied = Memory::new(std::sync::Arc::new(MemoryAccount::new(0, 1 << 24)));
+ assert!(matches!(
+ expand_char_refs_only(source, Accounted(&denied)),
+ Err(crate::xml::dom::ParseError::SharedExecution(
+ MemoryError::LimitExceeded
+ ))
+ ));
+ assert_eq!(denied.used(), 0);
+ }
// --- DTD Parsing Tests ---
@@ -3574,15 +4023,18 @@
#[test]
fn test_content_model_display() {
- assert_eq!(ContentModel::Empty.to_string(), "EMPTY");
- assert_eq!(ContentModel::Any.to_string(), "ANY");
- assert_eq!(ContentModel::Mixed(vec![]).to_string(), "(#PCDATA)");
+ assert_eq!(ContentModel::<Native>::Empty.to_string(), "EMPTY");
+ assert_eq!(ContentModel::<Native>::Any.to_string(), "ANY");
+ assert_eq!(
+ ContentModel::<Native>::Mixed(vec![]).to_string(),
+ "(#PCDATA)"
+ );
assert_eq!(
- ContentModel::Mixed(vec!["a".to_string(), "b".to_string()]).to_string(),
+ ContentModel::<Native>::Mixed(vec!["a".to_string(), "b".to_string()]).to_string(),
"(#PCDATA|a|b)*"
);
- let spec = ContentSpec {
+ let spec: ContentSpec = ContentSpec {
kind: ContentSpecKind::Seq(vec![
ContentSpec {
kind: ContentSpecKind::Name("a".to_string()),
@@ -3715,6 +4167,25 @@
}
#[test]
+ fn content_model_at_depth_ceiling_can_copy_serialize_and_drop() {
+ // Valid groups at the ceiling exercise the retained AST, not just
+ // rejection before the parser has constructed nested nodes.
+ let mut source = String::from("<!ELEMENT root ");
+ for _ in 0..MAX_CONTENT_MODEL_DEPTH {
+ source.push_str("(a,");
+ }
+ source.push('b');
+ for _ in 0..MAX_CONTENT_MODEL_DEPTH {
+ source.push(')');
+ }
+ source.push('>');
+ let dtd = parse_dtd(&source).unwrap();
+ let rendered = serialize_dtd(&dtd);
+ let reparsed = parse_dtd(&rendered).unwrap();
+ assert_eq!(serialize_dtd(&reparsed), rendered);
+ }
+
+ #[test]
fn test_content_model_deep_nesting_is_bounded() {
// Regression test for CVE-2026-61727 (GHSA-7jmw-29gc-ffx4): a content
// model with deeply nested '(' must return a normal error instead of
@@ -3731,9 +4202,9 @@
panic!("expected deep content model to be rejected, got Ok");
};
assert!(
- err.message.contains("maximum depth"),
+ err.syntax_message().contains("maximum depth"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
@@ -3750,9 +4221,9 @@
fn test_entity_recursion_direct_cycle_rejected() {
let err = parse_dtd("<!ENTITY x \"&x;\">").unwrap_err();
assert!(
- err.message.contains("recursive entity reference"),
+ err.syntax_message().contains("recursive entity reference"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
@@ -3761,9 +4232,9 @@
let err =
parse_dtd("<!ENTITY a \"&b;\"><!ENTITY b \"&c;\"><!ENTITY c \"&a;\">").unwrap_err();
assert!(
- err.message.contains("recursive entity reference"),
+ err.syntax_message().contains("recursive entity reference"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
@@ -3779,9 +4250,9 @@
)
.unwrap_err();
assert!(
- err.message.contains("recursive entity reference"),
+ err.syntax_message().contains("recursive entity reference"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
@@ -3820,13 +4291,109 @@
}
#[test]
+ fn entity_declaration_chain_does_not_use_the_call_stack() {
+ // Declaration depth is independent of element depth. Start explicitly
+ // at the chain head so hash-table iteration cannot hide recursive DFS.
+ let mut parser = DtdParser::new("");
+ for index in 0..8192 {
+ let name = format!("e{index}");
+ Names::insert(
+ &mut parser.dtd.entities,
+ name.clone(),
+ EntityDecl {
+ name,
+ kind: EntityKind::Internal(if index == 8191 {
+ "leaf".to_owned()
+ } else {
+ format!("&e{};", index + 1)
+ }),
+ },
+ )
+ .unwrap();
+ }
+ let mut visited = Native.names();
+ Names::insert(&mut visited, "e0", ()).unwrap();
+ let mut safe = Native.names();
+ let EntityKind::Internal(value) = &parser.dtd.entities["e0"].kind else {
+ unreachable!()
+ };
+ parser
+ .check_entity_recursion(value, &mut visited, &mut safe)
+ .unwrap();
+ assert_eq!(safe.len(), 8191);
+ assert_eq!(visited.len(), 1);
+ assert!(visited.contains_key("e0"));
+ }
+
+ #[test]
+ fn parameter_declaration_chain_does_not_use_the_call_stack() {
+ // Encoded PE references need the same stack safety as general entities;
+ // declaration graphs are not constrained by the document element depth.
+ let mut parser = DtdParser::new("");
+ for index in 0..8192 {
+ let name = format!("e{index}");
+ Names::insert(
+ &mut parser.dtd.param_entities,
+ name.clone(),
+ EntityDecl {
+ name,
+ kind: EntityKind::Internal(if index == 8191 {
+ "leaf".to_owned()
+ } else {
+ format!("%e{};", index + 1)
+ }),
+ },
+ )
+ .unwrap();
+ }
+ let mut visited = Native.names();
+ Names::insert(&mut visited, "e0", ()).unwrap();
+ let mut safe = Native.names();
+ parser
+ .check_pe_recursion("%e1;", &mut visited, &mut safe)
+ .unwrap();
+ assert_eq!(safe.len(), 8191);
+ assert_eq!(visited.len(), 1);
+ assert!(visited.contains_key("e0"));
+ }
+
+ #[test]
+ fn attribute_default_entity_chain_does_not_use_the_call_stack() {
+ // Acyclicity has already been established, but validating the same
+ // graph for attribute defaults must not reintroduce native recursion.
+ let mut parser = DtdParser::new("");
+ for index in 0..8192 {
+ let name = format!("e{index}");
+ Names::insert(
+ &mut parser.dtd.entities,
+ name.clone(),
+ EntityDecl {
+ name,
+ kind: EntityKind::Internal(if index == 8191 {
+ "leaf".to_owned()
+ } else {
+ format!("&e{};", index + 1)
+ }),
+ },
+ )
+ .unwrap();
+ }
+ let mut checked = Native.names();
+ parser
+ .validate_attr_default_entities("&e0;", &mut checked)
+ .unwrap();
+ assert_eq!(checked.len(), 8192);
+ }
+
+ #[test]
fn test_pe_recursion_direct_cycle_rejected() {
// %p; encoded as %p; inside the value (XML 1.0 §4.1).
let err = parse_dtd("<!ENTITY % p \"%p;\">").unwrap_err();
assert!(
- err.message.contains("recursive parameter entity reference"),
+ err.syntax_message()
+ .contains("recursive parameter entity reference"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
@@ -3834,9 +4401,10 @@
fn test_pe_recursion_indirect_cycle_rejected() {
let err = parse_dtd("<!ENTITY % p \"%q;\"><!ENTITY % q \"%p;\">").unwrap_err();
assert!(
- err.message.contains("recursive parameter entity reference"),
+ err.syntax_message()
+ .contains("recursive parameter entity reference"),
"unexpected error message: {}",
- err.message
+ err.syntax_message()
);
}
@@ -5,8 +5,6 @@
//! formatting options. Includes Canonical XML (C14N) serialization for
//! producing deterministic byte sequences required by XML digital signatures.
-pub mod c14n;
-pub mod html;
pub mod xml;
-pub use xml::{serialize, serialize_with_options, SerializeOptions};
+pub use xml::{SerializeOptions, serialize, serialize_with_options};
@@ -2,13 +2,13 @@
//!
//! Serializes a `Document` tree into a well-formed XML string.
-use crate::tree::{Document, NodeId, NodeKind};
+use crate::parser_xmloxide::tree::{Document, NodeId, NodeKind};
/// Options controlling XML serialization output.
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::Document;
/// use xmloxide::serial::{serialize_with_options, SerializeOptions};
///
@@ -65,7 +65,7 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::Document;
/// use xmloxide::serial::serialize;
///
@@ -85,7 +85,7 @@
///
/// # Examples
///
-/// ```
+/// ```text,xml-sec-example
/// use xmloxide::Document;
/// use xmloxide::serial::{serialize_with_options, SerializeOptions};
///
@@ -213,12 +213,11 @@
out.push('\n');
}
for child in doc.children(id) {
- if element_only {
- if let NodeKind::Text { content } = &doc.node(child).kind {
- if content.trim().is_empty() {
- continue;
- }
- }
+ if element_only
+ && let NodeKind::Text { content } = &doc.node(child).kind
+ && content.trim().is_empty()
+ {
+ continue;
}
serialize_node(
doc,
@@ -313,7 +312,7 @@
}
_ => {}
}
- if let Some(ref subset) = internal_subset {
+ if let Some(subset) = internal_subset {
out.push_str(" [");
out.push_str(subset);
out.push_str("]>");
@@ -549,7 +548,7 @@
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
- use crate::tree::Attribute;
+ use crate::parser_xmloxide::tree::Attribute;
#[test]
fn test_serialize_empty_element() {