From 4856a3fcf4b903de9843f85ffd54fe40d3e2f018 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 00:40:34 +0200 Subject: [PATCH] feat(parser): add bounded-by-default limits (depth, name, content) Add PureXML.Parsing.Limits: configurable caps on nesting depth (default 256, matching libxml2), name length, and content length, enforced during scanning in the event reader. This protects against pathological input (deeply nested or oversized documents) and keeps the recursive Model.Node value safe to build and tear down (extreme depth otherwise overflows on ARC release). Threaded through Parser.parse and the top-level parse/events API. Reimplemented from libxml2's bounded-by-default posture; no upstream code copied. --- CHANGELOG.md | 12 ++++++ Sources/API.swift | 22 ++++++---- Sources/Parsing/EventReader.swift | 70 +++++++++++++++++++------------ Sources/Parsing/Limits.swift | 28 +++++++++++++ Sources/Parsing/ParseError.swift | 9 ++++ Sources/Parsing/Parser.swift | 11 +++-- Tests/ParsingTests.swift | 31 ++++++++++++++ 7 files changed, 145 insertions(+), 38 deletions(-) create mode 100644 Sources/Parsing/Limits.swift 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 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")