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")