perf(html): make HTML serialization depth-safe (#353)

`PureXML.HTML.serialize` walked an element's children recursively, so a
deeply-nested tree overflowed the stack (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.emit`: 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, so nested
elements still serialize by their own name.

The output is byte-identical. Void-element, raw-text, comment, and escaping
semantics are unchanged (a new micro-test pins them) and the HTML conformance
corpus passes. A 50000-deep tree now serializes without crashing, guarded by
`HTMLSerializeDepthSafetyTests`. Part of #350.
This commit is contained in:
Mihaela Mihaljevic 2026-06-27 16:22:54 +02:00 committed by GitHub
parent 48c2c5c7db
commit 37bf30818f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 94 additions and 33 deletions

View File

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

View File

@ -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):
"<!--\(PureXML.Emitting.Escaping.comment(value))-->"
output += "<!--\(PureXML.Emitting.Escaping.comment(value))-->"
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)
}

View File

@ -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: "<div>", count: depth) + "leaf"))
#expect(html.hasSuffix(String(repeating: "</div>", count: depth)))
}
@Test("raw-text, void, comment, and escaping semantics are unchanged")
func test_semanticsPreserved() {
// A raw-text element writes its own text verbatim; a nested element still
// serializes by its own name; text outside is escaped; a void element has
// no end tag and drops nested content.
let tree: PureXML.Model.Node = .element(.init("body", children: [
.element(.init("script", children: [.text("if (a < b) x()")])),
.text("a < b & c"),
.element(.init("br", children: [.text("dropped")])),
.comment(" note "),
]))
let html = PureXML.HTML.serialize(tree)
#expect(html == "<body><script>if (a < b) x()</script>a &lt; b &amp; c<br><!-- note --></body>")
}
}