diff --git a/CHANGELOG.md b/CHANGELOG.md index 6131900..106f75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Serializing a tree as HTML (`PureXML.HTML.serialize`, the html output method's serializer) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested tree (as shallow as a couple of thousand deep on a Swift Task stack). The serializer now drives a deferred-close work stack (the same shape as the XML serializer and `Canonicalizer`): an element pushes a close step then its children reversed, and a raw-text element (`script`, `style`) flags its direct text children to emit verbatim. The output is byte-identical: the void-element, raw-text, comment, and escaping semantics are unchanged, with the HTML conformance corpus passing. A 50000-deep tree now serializes without crashing. (#350) + - Canonicalizing a document (`Canonicalizer`, the C14N form used for XML signatures) no longer recurses on the document's depth, so it no longer overflows the stack on a deeply-nested document (it crashed around 5000 deep). The serializer now drives a deferred-close work stack that threads each element's namespace context to its children. The canonical bytes are unchanged: the C14N 1.0/1.1 and exclusive conformance corpora pass. (#350) - Applying DTD attribute defaults to a document (`parseApplyingInternalDTDDefaults`, and the defaults pass behind DTD validation) no longer recurses on the document's depth, so it no longer overflows the stack on a deeply-nested document. The tree is rebuilt bottom-up through a new shared iterative spine (`Node.rebuildingBottomUp`), which the other tree-to-tree transforms will adopt. (#350) diff --git a/Sources/HTML/HTMLSerializer.swift b/Sources/HTML/HTMLSerializer.swift index 7109dff..3190392 100644 --- a/Sources/HTML/HTMLSerializer.swift +++ b/Sources/HTML/HTMLSerializer.swift @@ -5,45 +5,53 @@ extension PureXML.HTML { /// attribute values are escaped per HTML. enum Serializer { static func serialize(_ node: PureXML.Model.Node) -> String { + // Deferred-close work stack so a deeply-nested tree does not overflow + // the stack (the same shape as the XML serializer and Canonicalizer); + // children push reversed to emit in document order. + var output = "" + var stack: [HTMLSerializeStep] = [.node(node, rawTextParent: false)] + while let step = stack.popLast() { + switch step { + case let .close(name): + output += "\(name)>" + case let .node(node, rawTextParent): + emit(node, rawTextParent: rawTextParent, into: &output, stack: &stack) + } + } + return output + } + + private static func emit( + _ node: PureXML.Model.Node, + rawTextParent: Bool, + into output: inout String, + stack: inout [HTMLSerializeStep], + ) { switch node { case let .document(children): - children.map(serialize).joined() + stack.append(contentsOf: children.reversed().map { HTMLSerializeStep.node($0, rawTextParent: false) }) case let .element(element): - serializeElement(element) + let name = element.name.description + output += "<" + name + for attribute in element.attributes { + output += attributeText(attribute) + } + output += ">" + // A void element has no end tag and no content (the html model); + // anything nested is dropped, as the recursive form did. + if Elements.void.contains(name.lowercased()) { return } + stack.append(HTMLSerializeStep.close(name)) + // A raw-text element (script, style) writes its own text children + // verbatim; nested elements still serialize normally by their own + // name, so the flag governs only this element's direct text. + let childRawText = Elements.rawText.contains(name.lowercased()) + stack.append(contentsOf: element.children.reversed().map { HTMLSerializeStep.node($0, rawTextParent: childRawText) }) case let .text(value), let .cdata(value): - escapeText(value) + output += rawTextParent ? value : escapeText(value) case let .comment(value): - "" + output += "" case let .processingInstruction(target, data): - data.isEmpty ? "\(target)>" : "\(target) \(data)>" - } - } - - private static func serializeElement(_ element: PureXML.Model.Element) -> String { - let name = element.name.description - var output = "<" + name - for attribute in element.attributes { - output += attributeText(attribute) - } - output += ">" - if Elements.void.contains(name.lowercased()) { - return output - } - output += content(of: element, name: name) - return output + "\(name)>" - } - - private static func content(of element: PureXML.Model.Element, name: String) -> String { - if Elements.rawText.contains(name.lowercased()) { - return element.children.map(rawText).joined() - } - return element.children.map(serialize).joined() - } - - private static func rawText(_ node: PureXML.Model.Node) -> String { - switch node { - case let .text(value), let .cdata(value): value - default: serialize(node) + output += data.isEmpty ? "\(target)>" : "\(target) \(data)>" } } @@ -176,3 +184,12 @@ public extension PureXML.HTML { Serializer.serialize(node) } } + +/// One unit of HTML serialization work: a node to emit (with whether its parent +/// is a raw-text element, so its own text is written unescaped) or a close tag +/// deferred until after the element's children. File-scoped so the serializer's +/// deferred-close work stack is not a third level of type nesting. +private enum HTMLSerializeStep { + case node(PureXML.Model.Node, rawTextParent: Bool) + case close(String) +} diff --git a/Tests/HTMLSerializeDepthSafetyTests.swift b/Tests/HTMLSerializeDepthSafetyTests.swift new file mode 100644 index 0000000..f238dec --- /dev/null +++ b/Tests/HTMLSerializeDepthSafetyTests.swift @@ -0,0 +1,42 @@ +import Testing +@testable import PureXML + +/// HTML serialization must not recurse on a tree's depth (#350). The serializer +/// walked an element's children recursively, so it overflowed the stack on a +/// deeply-nested tree (as shallow as a couple of thousand deep on the Swift Task +/// stack these tests run on). It now drives a deferred-close work stack; these +/// tests reach depths the recursive form could not survive, and pin the +/// raw-text, void, and escaping semantics the refactor must preserve. +@Suite("HTML serialize depth safety") +struct HTMLSerializeDepthSafetyTests { + private func deepTree(_ depth: Int, leaf: String) -> PureXML.Model.Node { + var node: PureXML.Model.Node = .text(leaf) + for _ in 0 ..< depth { + node = .element(.init("div", children: [node])) + } + return node + } + + @Test("serialize on a 50k-deep tree emits balanced tags without overflowing") + func test_deepSerializeIsBounded() { + let depth = 50000 + let html = PureXML.HTML.serialize(deepTree(depth, leaf: "leaf")) + #expect(html.hasPrefix(String(repeating: "