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:
parent
48c2c5c7db
commit
37bf30818f
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
42
Tests/HTMLSerializeDepthSafetyTests.swift
Normal file
42
Tests/HTMLSerializeDepthSafetyTests.swift
Normal 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 < b & c<br><!-- note --></body>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user