diff --git a/CHANGELOG.md b/CHANGELOG.md
index c58f542..fe63b84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Configurable, bounded-by-default parser limits (`PureXML.Parsing.Limits`):
+ maximum nesting depth (default 256), name length, and content length, enforced
+ during scanning. Protects against pathological input and keeps the recursive
+ node model safe to build and tear down. Threaded through `parse`/`events`.
- Initial package scaffold: root Swift package, namespacing under `PureXML`, and
the portability and verification gates carried over from PureYAML.
- XML node model (`PureXML.Model`): `Node`, `Element`, `Attribute`, and
@@ -27,3 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
processing instructions. Safe by default: `` is rejected (no DTD/XXE).
- Top-level API: `PureXML.parse(_:)`, `PureXML.parse(pulling:)`, and
`PureXML.events(_:)` / `PureXML.events(pulling:)` for streaming.
+
+### Changed
+
+- The emitter is now iterative (explicit work stack, not recursion), mirroring
+ libxml2's approach, so deep trees do not overflow the call stack.
+- The emitter escapes tab/newline/CR (and `>`) in attribute values so attributes
+ round-trip through spec-compliant parsers; mixed content is no longer
+ reformatted (formatting is suppressed for elements with text/CDATA children).
diff --git a/Sources/API.swift b/Sources/API.swift
index 8ad087b..f4520cc 100644
--- a/Sources/API.swift
+++ b/Sources/API.swift
@@ -4,27 +4,33 @@ public extension PureXML {
/// The parser is still being built; this entry point currently raises
/// ``Parsing/ParseError/notImplemented(_:)``. The serializer and model are
/// usable today for building and emitting trees.
- static func parse(_ xml: String) throws -> Model.Node {
- try Parsing.Parser().parse(xml)
+ static func parse(_ xml: String, limits: Parsing.Limits = .default) throws -> Model.Node {
+ try Parsing.Parser().parse(xml, limits: limits)
}
/// Parses an XML document from an incremental character source into a
/// ``Model/Node`` tree. The closure returns the next character or nil at end
/// of input, so the document can arrive in chunks and is never held whole.
- static func parse(pulling pull: @escaping () -> Character?) throws -> Model.Node {
- try Parsing.Parser().parse(pulling: pull)
+ static func parse(
+ pulling pull: @escaping () -> Character?,
+ limits: Parsing.Limits = .default,
+ ) throws -> Model.Node {
+ try Parsing.Parser().parse(pulling: pull, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over an XML string. Pull events
/// one at a time with `next()` to process documents without building a tree.
- static func events(_ xml: String) -> Parsing.EventReader {
- Parsing.EventReader(xml)
+ static func events(_ xml: String, limits: Parsing.Limits = .default) -> Parsing.EventReader {
+ Parsing.EventReader(xml, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over an incremental character
/// source, for processing arbitrarily large or chunked input.
- static func events(pulling pull: @escaping () -> Character?) -> Parsing.EventReader {
- Parsing.EventReader(pulling: pull)
+ static func events(
+ pulling pull: @escaping () -> Character?,
+ limits: Parsing.Limits = .default,
+ ) -> Parsing.EventReader {
+ Parsing.EventReader(pulling: pull, limits: limits)
}
/// Serializes a ``Model/Node`` tree into XML with the selected options.
diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift
index 21219c1..2d95882 100644
--- a/Sources/Parsing/EventReader.swift
+++ b/Sources/Parsing/EventReader.swift
@@ -10,9 +10,10 @@ public extension PureXML.Parsing {
///
/// Safe by default: a `` declaration is rejected rather than
/// processed, removing the DTD-based threat classes (XXE, entity-expansion
- /// DoS) at the door.
+ /// DoS) at the door. Bounded ``Limits`` cap depth, name, and content size.
struct EventReader {
private var reader: Reader
+ private let limits: Limits
private var open: [PureXML.Model.QualifiedName] = []
private var pending: [Event] = []
private var sawRoot = false
@@ -20,13 +21,15 @@ public extension PureXML.Parsing {
/// Creates a reader over a character-pulling source. The closure returns
/// the next character or nil at end of input.
- init(pulling pull: @escaping () -> Character?) {
+ init(pulling pull: @escaping () -> Character?, limits: Limits = .default) {
reader = Reader(pulling: pull)
+ self.limits = limits
}
/// Creates a reader over a string (a convenience over the streaming init).
- init(_ string: String) {
+ init(_ string: String, limits: Limits = .default) {
reader = Reader(string)
+ self.limits = limits
}
/// Returns the next event, or nil at the end of the document.
@@ -52,26 +55,19 @@ public extension PureXML.Parsing {
if reader.matches("") {
guard let character = reader.advance() else {
throw ParseError.unterminatedComment(mark)
}
+ length += 1
+ try checkContent(length, mark)
content.append(character)
}
reader.consume("-->")
@@ -167,10 +164,13 @@ public extension PureXML.Parsing {
let mark = reader.mark
reader.consume("") {
guard let character = reader.advance() else {
throw ParseError.unterminatedCDATA(mark)
}
+ length += 1
+ try checkContent(length, mark)
content.append(character)
}
reader.consume("]]>")
@@ -183,10 +183,13 @@ public extension PureXML.Parsing {
let target = try scanName()
reader.skipSpace()
var data = ""
+ var length = 0
while !reader.matches("?>") {
guard let character = reader.advance() else {
throw ParseError.unterminatedTag(mark)
}
+ length += 1
+ try checkContent(length, mark)
data.append(character)
}
reader.consume("?>")
@@ -223,7 +226,10 @@ public extension PureXML.Parsing {
}
reader.advance()
var raw = ""
+ var length = 0
while let character = reader.peek(), character != quote {
+ length += 1
+ try checkContent(length, mark)
raw.append(character)
reader.advance()
}
@@ -234,15 +240,27 @@ public extension PureXML.Parsing {
}
private mutating func scanName() throws -> PureXML.Model.QualifiedName {
+ let mark = reader.mark
guard let first = reader.peek(), first.isXMLNameStart else {
throw ParseError.expectedName(reader.mark)
}
var raw = ""
+ var length = 0
while let character = reader.peek(), character.isXMLNameContinuation {
+ length += 1
+ guard length <= limits.maxNameLength else {
+ throw ParseError.nameTooLong(limit: limits.maxNameLength, mark)
+ }
raw.append(character)
reader.advance()
}
return PureXML.Model.QualifiedName(raw)
}
+
+ private func checkContent(_ length: Int, _ mark: Mark) throws {
+ guard length <= limits.maxContentLength else {
+ throw ParseError.contentTooLong(limit: limits.maxContentLength, mark)
+ }
+ }
}
}
diff --git a/Sources/Parsing/Limits.swift b/Sources/Parsing/Limits.swift
new file mode 100644
index 0000000..095d2ee
--- /dev/null
+++ b/Sources/Parsing/Limits.swift
@@ -0,0 +1,28 @@
+public extension PureXML.Parsing {
+ /// Configurable, bounded-by-default parser limits. Exceeding any of them is a
+ /// parse error rather than unbounded work, which protects against pathological
+ /// input (deeply nested or oversized documents). The depth cap also keeps the
+ /// recursive ``PureXML/Model/Node`` value safe to build and tear down. The
+ /// defaults follow libxml2's posture.
+ struct Limits: Equatable, Sendable {
+ /// Maximum element nesting depth (libxml2 defaults to 256).
+ public var maxDepth: Int
+ /// Maximum length of a single name (element, attribute, or PI target).
+ public var maxNameLength: Int
+ /// Maximum length of a single text, CDATA, comment, or PI-data run.
+ public var maxContentLength: Int
+
+ public init(
+ maxDepth: Int = 256,
+ maxNameLength: Int = 50000,
+ maxContentLength: Int = 10_000_000,
+ ) {
+ self.maxDepth = maxDepth
+ self.maxNameLength = maxNameLength
+ self.maxContentLength = maxContentLength
+ }
+
+ /// The default limits, matching libxml2's bounded-by-default posture.
+ public static let `default` = Limits()
+ }
+}
diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift
index 70944e9..fecea42 100644
--- a/Sources/Parsing/ParseError.swift
+++ b/Sources/Parsing/ParseError.swift
@@ -15,6 +15,9 @@ public extension PureXML.Parsing {
case invalidReference(String, Mark)
case unexpectedEndTag(name: String, Mark)
case junkAfterDocumentElement(Mark)
+ case nestingTooDeep(limit: Int, Mark)
+ case nameTooLong(limit: Int, Mark)
+ case contentTooLong(limit: Int, Mark)
/// A `` declaration was found. DTD processing is disabled by
/// default as a security posture (XXE, entity-expansion DoS); enabling it
/// is a deliberate future opt-in, not silent behavior.
@@ -52,6 +55,12 @@ public extension PureXML.Parsing {
"unexpected end tag \(name)> at \(mark)"
case let .junkAfterDocumentElement(mark):
"content after the root element at \(mark)"
+ case let .nestingTooDeep(limit, mark):
+ "element nesting exceeds the limit of \(limit) at \(mark)"
+ case let .nameTooLong(limit, mark):
+ "name exceeds the length limit of \(limit) at \(mark)"
+ case let .contentTooLong(limit, mark):
+ "content exceeds the length limit of \(limit) at \(mark)"
case let .unsupportedDoctype(mark):
"DTD processing is disabled (DOCTYPE at \(mark))"
case let .notImplemented(detail):
diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift
index 055288a..5158cf6 100644
--- a/Sources/Parsing/Parser.swift
+++ b/Sources/Parsing/Parser.swift
@@ -16,15 +16,18 @@ public extension PureXML.Parsing {
public init() {}
/// Parses a single XML document from a string into a document node.
- public func parse(_ xml: String) throws -> PureXML.Model.Node {
- try build(EventReader(xml))
+ public func parse(_ xml: String, limits: Limits = .default) throws -> PureXML.Model.Node {
+ try build(EventReader(xml, limits: limits))
}
/// Parses a single XML document from an incremental character source. The
/// closure returns the next character or nil at end of input, so the
/// document can arrive in chunks and is never held whole.
- public func parse(pulling pull: @escaping () -> Character?) throws -> PureXML.Model.Node {
- try build(EventReader(pulling: pull))
+ public func parse(
+ pulling pull: @escaping () -> Character?,
+ limits: Limits = .default,
+ ) throws -> PureXML.Model.Node {
+ try build(EventReader(pulling: pull, limits: limits))
}
private func build(_ source: EventReader) throws -> PureXML.Model.Node {
diff --git a/Tests/ParsingTests.swift b/Tests/ParsingTests.swift
index 294a854..99474c9 100644
--- a/Tests/ParsingTests.swift
+++ b/Tests/ParsingTests.swift
@@ -85,6 +85,37 @@ struct ParsingTests {
}
}
+ @Test("Nesting beyond the depth limit is rejected")
+ func test_depthLimitRejected() {
+ let limits = PureXML.Parsing.Limits(maxDepth: 3)
+ #expect(throws: PureXML.Parsing.ParseError.self) {
+ try PureXML.parse("x", limits: limits)
+ }
+ }
+
+ @Test("Nesting within the depth limit parses")
+ func test_depthWithinLimit() throws {
+ let limits = PureXML.Parsing.Limits(maxDepth: 5)
+ let node = try PureXML.parse("x", limits: limits)
+ #expect(node.firstElement?.name.localName == "a")
+ }
+
+ @Test("Names over the length limit are rejected")
+ func test_nameLimitRejected() {
+ let limits = PureXML.Parsing.Limits(maxNameLength: 4)
+ #expect(throws: PureXML.Parsing.ParseError.self) {
+ try PureXML.parse("", limits: limits)
+ }
+ }
+
+ @Test("Content over the length limit is rejected")
+ func test_contentLimitRejected() {
+ let limits = PureXML.Parsing.Limits(maxContentLength: 5)
+ #expect(throws: PureXML.Parsing.ParseError.self) {
+ try PureXML.parse("abcdefghij", limits: limits)
+ }
+ }
+
@Test("Streams events one at a time without building a tree")
func test_streamsEvents() throws {
var reader = PureXML.events("hi")