From b1d82e533d3b32fb31d7884452ce451dd711dd32 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 12:15:18 +0200 Subject: [PATCH] feat(parsing): recovering reader that never crashes on invalid input Slice 2 of #92. Add PureXML.read, a deterministic recovering reader: it never throws and never traps, returning the maximal best-effort tree plus one located Diagnostic per problem. - ReadResult { node, diagnostics } is the Equatable result type, so a deterministic read can be compared whole. - Diagnostic carries a message and a structured source Mark; a thrown ParseError maps to one directly (ParseError.mark exposes the position each case carries). - The reader resynchronizes after a failure (EventReader.recover(from:)): progress-aware, so a failure that already consumed the bad construct retries from there, while a stuck failure skips to the next markup start; it always advances when input remains, guaranteeing termination. - The tree builder assembles leniently: elements left open by truncation are closed, an end tag with no open element is dropped. Well-formed input yields exactly parse(_:)'s tree with no diagnostics. Tests pin exact trees and diagnostics for truncation, mismatched tags, and stray junk, prove determinism (same bytes give an identical ReadResult), and fuzz 400 seeded inputs (arbitrary plus truncated valid docs) asserting no crash. Recovery here is resync-based; finer in-reader recovery (keep the element on a duplicate attribute, pop to a matching open tag) is the next slice. --- Sources/API.swift | 13 +++++ Sources/Parsing/Diagnostic.swift | 50 ++++++++++++++++++ Sources/Parsing/EventReader.swift | 29 +++++++++++ Sources/Parsing/Parser.swift | 79 ++++++++++++++++++++++++++++ Sources/Parsing/ReadResult.swift | 18 +++++++ Tests/RecoveringReaderTests.swift | 87 +++++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+) create mode 100644 Sources/Parsing/Diagnostic.swift create mode 100644 Sources/Parsing/ReadResult.swift create mode 100644 Tests/RecoveringReaderTests.swift diff --git a/Sources/API.swift b/Sources/API.swift index 8d470b0..a792396 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -125,6 +125,19 @@ public extension PureXML { try Model.TreeNode(parse(xml, limits: limits, resolver: resolver)) } + /// Reads a possibly-invalid document without throwing, returning the maximal + /// best-effort tree and one located ``Parsing/Diagnostic`` per problem. + /// Well-formed input gives the same node as ``parse(_:limits:resolver:)`` with + /// no diagnostics; malformed input is recovered deterministically rather than + /// rejected. The result never crashes, whatever the bytes. + static func read( + _ xml: String, + limits: Parsing.Limits = .default, + resolver: Parsing.EntityResolver = .refusing, + ) -> Parsing.ReadResult { + Parsing.Parser().read(xml, limits: limits, resolver: resolver) + } + /// Validates a parsed XML node with the default structural rules, throwing a /// ``PureXML/Validation/ValidationErrorCollection`` that locates every failure /// by coding path. Pass a custom ``PureXML/Validation/Validator`` to add or diff --git a/Sources/Parsing/Diagnostic.swift b/Sources/Parsing/Diagnostic.swift new file mode 100644 index 0000000..d979310 --- /dev/null +++ b/Sources/Parsing/Diagnostic.swift @@ -0,0 +1,50 @@ +public extension PureXML.Parsing { + /// A located problem found while reading a document. Unlike a thrown + /// ``ParseError``, a diagnostic does not abort the read: the recovering reader + /// records it, repairs as best it can, and continues, so an invalid document + /// still yields a best-effort tree alongside the list of what went wrong. + struct Diagnostic: Equatable, Sendable, CustomStringConvertible { + /// A human-readable, already-located description of the problem. + public var message: String + /// The source position, when known. + public var mark: Mark? + + public init(message: String, mark: Mark? = nil) { + self.message = message + self.mark = mark + } + + public var description: String { + message + } + } +} + +extension PureXML.Parsing.Diagnostic { + /// Wraps a thrown ``PureXML/Parsing/ParseError`` as a diagnostic, keeping its + /// located message and structured position. + init(_ error: PureXML.Parsing.ParseError) { + self.init(message: error.description, mark: error.mark) + } +} + +public extension PureXML.Parsing.ParseError { + /// The source position the error occurred at, when the case carries one. + var mark: PureXML.Parsing.Mark? { + switch self { + case .emptyDocument, .malformedEncoding, .notImplemented: + nil + case let .unexpectedEndOfInput(mark), let .unterminatedTag(mark), let .unterminatedComment(mark), + let .unterminatedCDATA(mark), let .expectedName(mark), let .expectedEquals(mark), + let .unquotedAttributeValue(mark), let .amplificationLimitExceeded(mark), + let .junkAfterDocumentElement(mark), let .unsupportedDoctype(mark): + mark + case let .unexpectedCharacter(_, mark), let .mismatchedEndTag(_, _, mark), + let .duplicateAttribute(_, mark), let .invalidReference(_, mark), + let .undefinedEntity(_, mark), let .recursiveEntity(_, mark), + let .unexpectedEndTag(_, mark), let .undefinedNamespacePrefix(_, mark), + let .nestingTooDeep(_, mark), let .nameTooLong(_, mark), let .contentTooLong(_, mark): + mark + } + } +} diff --git a/Sources/Parsing/EventReader.swift b/Sources/Parsing/EventReader.swift index 33b4350..355e9b1 100644 --- a/Sources/Parsing/EventReader.swift +++ b/Sources/Parsing/EventReader.swift @@ -124,6 +124,9 @@ public extension PureXML.Parsing { return .startElement(name: resolved.name, attributes: resolved.attributes) } guard reader.consume(">") else { + // Balance the namespace scope entered above so a recovering reader + // can drop this malformed start tag without leaking a scope. + namespaces.leaveElement() throw ParseError.unterminatedTag(reader.mark) } open.append(resolved.name) @@ -286,3 +289,29 @@ public extension PureXML.Parsing { } } } + +extension PureXML.Parsing.EventReader { + /// The current source offset, used to tell whether a failing ``next()`` made + /// progress before throwing. + var offset: Int { + reader.mark.offset + } + + /// Resynchronizes after a failed ``next()`` that started at `offset`, so + /// reading can resume past a recorded diagnostic. Returns false at end of + /// input. If the failed scan already advanced (it consumed the malformed + /// construct), reading retries from the new position; if it threw without + /// progress, the offending run is skipped up to the next markup start. Either + /// way at least one character is consumed when input remains, so a recovering + /// loop is guaranteed to terminate. + mutating func recover(from offset: Int) -> Bool { + pending.removeAll() + guard reader.peek() != nil else { return false } + if reader.mark.offset > offset { return true } + reader.advance() + while let character = reader.peek(), character != "<" { + reader.advance() + } + return true + } +} diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift index 09ab8a0..233ba81 100644 --- a/Sources/Parsing/Parser.swift +++ b/Sources/Parsing/Parser.swift @@ -37,6 +37,18 @@ public extension PureXML.Parsing { try build(EventReader(xml, limits: limits, resolver: resolver)) } + /// Reads a possibly-invalid document without ever throwing: returns the + /// maximal best-effort tree and a located ``Diagnostic`` for every problem + /// found. Well-formed input yields the same tree as ``parse(_:limits:resolver:)`` + /// with no diagnostics. See ``readRecovering(_:)`` for the recovery contract. + public func read( + _ xml: String, + limits: Limits = .default, + resolver: EntityResolver = .refusing, + ) -> ReadResult { + readRecovering(EventReader(xml, limits: limits, resolver: resolver)) + } + /// Parses a single XML document from raw bytes, detecting the encoding /// (UTF-8 or UTF-16, with or without a byte-order mark) before parsing. public func parse(bytes: [UInt8], limits: Limits = .default) throws -> PureXML.Model.Node { @@ -138,6 +150,73 @@ public extension PureXML.Parsing { return (.document(roots), reader.documentType) } + /// Drains the reader the way ``build`` does, but never throws: each error + /// is recorded as a ``Diagnostic`` and the reader resynchronizes past it, + /// so reading continues to the end of input. Elements left open by + /// truncated input are closed, and an end tag with no open element is + /// dropped, yielding the maximal best-effort tree. The transformation is + /// deterministic: the same input always produces the same tree and the + /// same diagnostics. + func readRecovering(_ source: EventReader) -> ReadResult { + var reader = source + var roots: [PureXML.Model.Node] = [] + var stack: [ElementFrame] = [] + var diagnostics: [Diagnostic] = [] + + loop: while true { + let start = reader.offset + let event: Event? + do { + event = try reader.next() + } catch let error as ParseError { + diagnostics.append(Diagnostic(error)) + if !reader.recover(from: start) { break loop } + continue + } catch { + diagnostics.append(Diagnostic(message: "\(error)")) + if !reader.recover(from: start) { break loop } + continue + } + guard let event else { break } + consume(event, into: &stack, roots: &roots, diagnostics: &diagnostics) + } + + // Close any elements the input left open (truncation), innermost first. + while !stack.isEmpty { + let frame = stack.removeLast() + let element = PureXML.Model.Element(name: frame.name, attributes: frame.attributes, children: frame.children) + attach(.element(element), to: &stack, roots: &roots) + } + return ReadResult(node: .document(roots), diagnostics: diagnostics) + } + + private func consume( + _ event: Event, + into stack: inout [ElementFrame], + roots: inout [PureXML.Model.Node], + diagnostics: inout [Diagnostic], + ) { + switch event { + case let .startElement(name, attributes): + stack.append(ElementFrame(name: name, attributes: attributes)) + case let .endElement(name): + guard let frame = stack.popLast() else { + diagnostics.append(Diagnostic(message: "unmatched end tag ")) + return + } + let element = PureXML.Model.Element(name: frame.name, attributes: frame.attributes, children: frame.children) + attach(.element(element), to: &stack, roots: &roots) + case let .characters(text): + attach(.text(text), to: &stack, roots: &roots) + case let .cdata(text): + attach(.cdata(text), to: &stack, roots: &roots) + case let .comment(text): + attach(.comment(text), to: &stack, roots: &roots) + case let .processingInstruction(target, data): + attach(.processingInstruction(target: target, data: data), to: &stack, roots: &roots) + } + } + private func attach( _ node: PureXML.Model.Node, to stack: inout [ElementFrame], diff --git a/Sources/Parsing/ReadResult.swift b/Sources/Parsing/ReadResult.swift new file mode 100644 index 0000000..e0e1b20 --- /dev/null +++ b/Sources/Parsing/ReadResult.swift @@ -0,0 +1,18 @@ +public extension PureXML.Parsing { + /// The outcome of a recovering read: the best-effort node tree and every + /// located ``Diagnostic`` found along the way. Equatable, so a deterministic + /// read can be compared whole. + struct ReadResult: Equatable, Sendable { + /// The best-effort tree. Always a document node; as complete as recovery + /// allowed. + public var node: PureXML.Model.Node + /// One diagnostic per problem, in source order. Empty when the input was + /// well-formed. + public var diagnostics: [Diagnostic] + + public init(node: PureXML.Model.Node, diagnostics: [Diagnostic]) { + self.node = node + self.diagnostics = diagnostics + } + } +} diff --git a/Tests/RecoveringReaderTests.swift b/Tests/RecoveringReaderTests.swift new file mode 100644 index 0000000..a11a9b4 --- /dev/null +++ b/Tests/RecoveringReaderTests.swift @@ -0,0 +1,87 @@ +@testable import PureXML +import Testing + +/// The recovering reader (`PureXML.read`) never throws and never crashes: it +/// returns the maximal best-effort tree plus one located diagnostic per problem. +/// Its recovery is deterministic, so every assertion here pins an exact result. +@Suite("Recovering reader") +struct RecoveringReaderTests { + @Test("Well-formed input matches the strict parser with no diagnostics") + func test_wellFormed() throws { + let xml = "x" + let result = PureXML.read(xml) + #expect(result.diagnostics.isEmpty) + #expect(try result.node == PureXML.parse(xml)) + } + + @Test("Truncated input is closed into a best-effort tree with one diagnostic") + func test_truncated() throws { + let result = PureXML.read("x") + #expect(try result.node == PureXML.parse("x")) + #expect(result.diagnostics.count == 1) + #expect(result.diagnostics[0].mark != nil) + #expect(result.diagnostics[0].message.contains("unexpected end of input")) + } + + @Test("A mismatched end tag is reported and the content is salvaged") + func test_mismatchedEndTag() throws { + let result = PureXML.read("x") + #expect(try result.node == PureXML.parse("x")) + #expect(result.diagnostics.count == 2) + #expect(result.diagnostics.allSatisfy { $0.message.contains("expected ") }) + } + + @Test("Junk around the root is dropped and the element is kept") + func test_strayJunk() throws { + let result = PureXML.read("garbagexz") + #expect(try result.node == PureXML.parse("x")) + #expect(!result.diagnostics.isEmpty) + } + + @Test("Empty input reads as an empty document without crashing") + func test_empty() { + let result = PureXML.read("") + #expect(result.node == .document([])) + #expect(result.diagnostics.isEmpty) + } + + @Test("Reading is deterministic: the same bytes give the same result") + func test_deterministic() { + for xml in ["x", "<<>>", "z", "deep"] { + #expect(PureXML.read(xml) == PureXML.read(xml)) + } + } + + @Test("Arbitrary and truncated input never crashes and stays deterministic") + func test_fuzzNeverCrashes() { + // A seeded generator (no Date/random) makes the sweep reproducible. + var state: UInt64 = 0x9E37_79B9_7F4A_7C15 + func nextByte() -> UInt8 { + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return UInt8((state >> 33) & 0xFF) + } + let alphabet = Array("<>/&;\"'= \nabcxyz![]-?CDATA") + let seed = "text" + + for iteration in 0 ..< 400 { + // Mix random characters with truncations of a valid document. + var input = "" + let length = Int(nextByte()) % 60 + for _ in 0 ..< length { + input.append(alphabet[Int(nextByte()) % alphabet.count]) + } + if iteration.isMultiple(of: 3) { + input += String(seed.prefix(Int(nextByte()) % seed.count + 1)) + } + let first = PureXML.read(input) + let second = PureXML.read(input) + // Completing without trapping is the no-crash assertion; also check the + // result is a document and the read is deterministic. + guard case .document = first.node else { + Issue.record("read did not return a document for input \(input.debugDescription)") + continue + } + #expect(first == second) + } + } +}