perf(canonical): make the prefix rewrite and node-subset emit depth-safe (#355)
Two Canonical XML transforms still recursed one native frame per element level, so a deeply-nested document overflowed the stack. The C14N 2.0 sequential prefix rewrite walks the tree twice. Its assign pass (which records one canonical prefix per namespace in document order) is now an iterative pre-order walk; its rebuild pass is now a child-gathering accumulator driven by an explicit work stack, threading the in-scope declarations and the root flag down per element. The document-order prefix assignment and the root-hoisted declarations are unchanged. The node-subset serializer (`canonicalize(_:including:)`) now drives the same deferred-close work stack as the main serializer, threading each element's in-scope and rendered namespace context; an omitted element still passes its declarations to descendants without rendering or a close tag. The canonical bytes are unchanged: the C14N exclusive, 2.0, and node-subset corpora pass (1794 tests). A 50000-deep document now rewrites and subset-canonicalizes without crashing, guarded by `CanonicalDepthSafetyTests`. Part of #350.
This commit is contained in:
parent
f4982219a6
commit
33160ef775
@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Processing `xi:include` elements (`PureXML.XInclude.process`) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested document, even one with no includes (as shallow as a couple of thousand deep on a Swift Task stack). The pass now drives an explicit work stack: each branch gathers its processed children into a reference accumulator, then a deferred build step assembles the node, while the `xml:base`, inclusion-chain depth, and cycle-detection visited set thread down exactly as before. An include splices its (or its fallback's) content in place by pushing the loaded nodes back onto the stack. Behavior is unchanged: the XInclude/XPointer conformance corpus and the fallback, xpointer, cycle, and `xml:base` cases pass. A 50000-deep document, with or without an include at its base, now processes without crashing. (#350)
|
||||
|
||||
- The two Canonical XML transforms that still recursed on a document's depth are now iterative, so they no longer overflow the stack on a deeply-nested tree (#350). The C14N 2.0 sequential prefix rewrite walks the tree twice (a document-order pass that assigns one canonical prefix per namespace, then a rebuild pass); both now drive explicit stacks, the assign pass a pre-order walk and the rebuild pass a child-gathering accumulator, with the in-scope declarations and the root flag threaded down per element. The node-subset serializer (`canonicalize(_:including:)`, the C14N form over a selected subtree) now drives the same deferred-close work stack as the main serializer, threading each element's in-scope and rendered namespace context, with an omitted element still passing its declarations to descendants. The canonical bytes are unchanged: the C14N exclusive, 2.0, and node-subset corpora pass. A 50000-deep document now rewrites and subset-canonicalizes 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)
|
||||
|
||||
@ -1,3 +1,20 @@
|
||||
/// A mutable bag of rebuilt nodes a branch's children gather into, so the
|
||||
/// canonical prefix rewrite's deferred build step assembles the parent once they
|
||||
/// are done.
|
||||
private final class CanonicalRewriteAccumulator {
|
||||
var nodes: [PureXML.Model.Node] = []
|
||||
}
|
||||
|
||||
/// One unit of canonical prefix-rewrite work: visit a source node (appending its
|
||||
/// rebuilt result to `target`), or, after a branch's children are gathered, build
|
||||
/// the element or document. File-scoped so the work stack is not a third level of
|
||||
/// type nesting.
|
||||
private enum CanonicalRewriteWork {
|
||||
case visit(PureXML.Model.Node, inScope: [String: String], isRoot: Bool, target: CanonicalRewriteAccumulator)
|
||||
case buildElement(PureXML.Model.Element, childInScope: [String: String], isRoot: Bool, gathered: CanonicalRewriteAccumulator, target: CanonicalRewriteAccumulator)
|
||||
case buildDocument(CanonicalRewriteAccumulator, CanonicalRewriteAccumulator)
|
||||
}
|
||||
|
||||
extension PureXML.Canonical {
|
||||
/// The Canonical XML 2.0 sequential prefix rewrite. Implemented as a pre-pass
|
||||
/// that rebuilds the tree using canonical prefixes (`n0`, `n1`, ... in
|
||||
@ -16,41 +33,47 @@ extension PureXML.Canonical {
|
||||
var assignment: [String: String] = [:]
|
||||
var order = 0
|
||||
assign(node, inScope: [:], labels: labels, &assignment, &order)
|
||||
return transform(node, inScope: [:], assignment: assignment, labels: labels, isRoot: true)
|
||||
return transform(node, assignment: assignment, labels: labels)
|
||||
}
|
||||
|
||||
// MARK: First pass: assign a canonical prefix per visibly-used namespace
|
||||
|
||||
private static func assign(
|
||||
_ node: PureXML.Model.Node,
|
||||
_ root: PureXML.Model.Node,
|
||||
inScope: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
_ assignment: inout [String: String],
|
||||
_ order: inout Int,
|
||||
) {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
for child in children {
|
||||
assign(child, inScope: inScope, labels: labels, &assignment, &order)
|
||||
// Iterative pre-order walk so a deeply-nested document does not overflow
|
||||
// the stack; children push reversed so namespaces are recorded in
|
||||
// document order, the order the canonical prefixes n0, n1, ... assign in.
|
||||
var stack: [(node: PureXML.Model.Node, inScope: [String: String])] = [(root, inScope)]
|
||||
while let (node, inScope) = stack.popLast() {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
for child in children.reversed() {
|
||||
stack.append((child, inScope))
|
||||
}
|
||||
case let .element(element):
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
record(element.name.namespaceURI, &assignment, &order)
|
||||
for attribute in element.attributes where attribute.name.prefix != nil {
|
||||
record(attribute.name.namespaceURI, &assignment, &order)
|
||||
}
|
||||
for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
|
||||
record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
|
||||
}
|
||||
record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
|
||||
for child in element.children.reversed() {
|
||||
stack.append((child, childInScope))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
case let .element(element):
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
record(element.name.namespaceURI, &assignment, &order)
|
||||
for attribute in element.attributes where attribute.name.prefix != nil {
|
||||
record(attribute.name.namespaceURI, &assignment, &order)
|
||||
}
|
||||
for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
|
||||
record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
|
||||
}
|
||||
record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
|
||||
for child in element.children {
|
||||
assign(child, inScope: childInScope, labels: labels, &assignment, &order)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,46 +96,72 @@ extension PureXML.Canonical {
|
||||
|
||||
// MARK: Second pass: rebuild with canonical prefixes
|
||||
|
||||
/// Rebuilds the tree with canonical prefixes without recursing on its depth.
|
||||
/// An explicit work stack threads the in-scope declarations and the root
|
||||
/// flag down per element, while each branch gathers its rebuilt children
|
||||
/// into a reference accumulator that a deferred build step assembles, so a
|
||||
/// deeply-nested document does not overflow the stack.
|
||||
private static func transform(
|
||||
_ node: PureXML.Model.Node,
|
||||
inScope: [String: String],
|
||||
_ root: PureXML.Model.Node,
|
||||
assignment: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
isRoot: Bool,
|
||||
) -> PureXML.Model.Node {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
.document(children.map { transform($0, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot) })
|
||||
case let .element(element):
|
||||
.element(transform(element, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot))
|
||||
default:
|
||||
node
|
||||
let result = CanonicalRewriteAccumulator()
|
||||
var stack: [CanonicalRewriteWork] = [.visit(root, inScope: [:], isRoot: true, target: result)]
|
||||
while let work = stack.popLast() {
|
||||
switch work {
|
||||
case let .visit(node, inScope, isRoot, target):
|
||||
visitForRewrite(node, inScope: inScope, isRoot: isRoot, target: target, stack: &stack)
|
||||
case let .buildElement(element, childInScope, isRoot, gathered, target):
|
||||
var attributes = isRoot ? canonicalDeclarations(assignment) : []
|
||||
for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
|
||||
attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
|
||||
}
|
||||
// A QName-labelled element's single text child is rewritten to a
|
||||
// canonical-prefixed QName, replacing its rebuilt children.
|
||||
let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
|
||||
target.nodes.append(.element(PureXML.Model.Element(
|
||||
name: rename(element.name, assignment: assignment),
|
||||
attributes: attributes,
|
||||
children: qualified.map { [PureXML.Model.Node.text($0)] } ?? gathered.nodes,
|
||||
)))
|
||||
case let .buildDocument(gathered, target):
|
||||
target.nodes.append(.document(gathered.nodes))
|
||||
}
|
||||
}
|
||||
return result.nodes.first ?? root
|
||||
}
|
||||
|
||||
private static func transform(
|
||||
_ element: PureXML.Model.Element,
|
||||
/// Pushes a node's rebuild work: a document or element schedules its build
|
||||
/// step then its children (with the in-scope declarations extended), a leaf
|
||||
/// is appended to `target` unchanged.
|
||||
private static func visitForRewrite(
|
||||
_ node: PureXML.Model.Node,
|
||||
inScope: [String: String],
|
||||
assignment: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
isRoot: Bool,
|
||||
) -> PureXML.Model.Element {
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
target: CanonicalRewriteAccumulator,
|
||||
stack: inout [CanonicalRewriteWork],
|
||||
) {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
let gathered = CanonicalRewriteAccumulator()
|
||||
stack.append(.buildDocument(gathered, target))
|
||||
for child in children.reversed() {
|
||||
stack.append(.visit(child, inScope: inScope, isRoot: isRoot, target: gathered))
|
||||
}
|
||||
case let .element(element):
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
let gathered = CanonicalRewriteAccumulator()
|
||||
stack.append(.buildElement(element, childInScope: childInScope, isRoot: isRoot, gathered: gathered, target: target))
|
||||
for child in element.children.reversed() {
|
||||
stack.append(.visit(child, inScope: childInScope, isRoot: false, target: gathered))
|
||||
}
|
||||
default:
|
||||
target.nodes.append(node)
|
||||
}
|
||||
|
||||
var attributes = isRoot ? canonicalDeclarations(assignment) : []
|
||||
for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
|
||||
attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
|
||||
}
|
||||
let children = element.children.map { transform($0, inScope: childInScope, assignment: assignment, labels: labels, isRoot: false) }
|
||||
let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
|
||||
return PureXML.Model.Element(
|
||||
name: rename(element.name, assignment: assignment),
|
||||
attributes: attributes,
|
||||
children: qualified.map { [PureXML.Model.Node.text($0)] } ?? children,
|
||||
)
|
||||
}
|
||||
|
||||
/// One `xmlns:nK="uri"` declaration per assigned namespace, hoisted to the
|
||||
|
||||
8
Sources/Canonical/CanonicalSelectedStep.swift
Normal file
8
Sources/Canonical/CanonicalSelectedStep.swift
Normal file
@ -0,0 +1,8 @@
|
||||
/// One step of the iterative canonical node-subset serialization (C14N over a
|
||||
/// selected subtree): a `TreeNode` to emit with its in-scope and already-rendered
|
||||
/// namespace context, or a deferred element close. The mutable-tree counterpart
|
||||
/// to ``CanonicalStep``.
|
||||
enum CanonicalSelectedStep {
|
||||
case open(PureXML.Model.TreeNode, inScope: [String: String], rendered: [String: String])
|
||||
case close(String)
|
||||
}
|
||||
@ -252,49 +252,54 @@ public extension PureXML.Canonical.Canonicalizer {
|
||||
}
|
||||
|
||||
private func emitSelected(
|
||||
_ root: PureXML.Model.TreeNode,
|
||||
inScope: [String: String],
|
||||
rendered: [String: String],
|
||||
including predicate: (PureXML.Model.TreeNode) -> Bool,
|
||||
output: inout String,
|
||||
) {
|
||||
// Deferred-close work stack so a deeply-nested subtree does not overflow
|
||||
// the stack; children push reversed to emit in document order, threading
|
||||
// each element's in-scope and already-rendered namespace context.
|
||||
var stack: [CanonicalSelectedStep] = [.open(root, inScope: inScope, rendered: rendered)]
|
||||
while let step = stack.popLast() {
|
||||
switch step {
|
||||
case let .close(name):
|
||||
output += "</\(name)>"
|
||||
case let .open(node, inScope, rendered):
|
||||
switch node.kind {
|
||||
case .document:
|
||||
for child in node.children.reversed() {
|
||||
stack.append(.open(child, inScope: inScope, rendered: rendered))
|
||||
}
|
||||
case .element:
|
||||
stack.append(contentsOf: selectedElementSteps(node, inScope: inScope, rendered: rendered, including: predicate, into: &output))
|
||||
default:
|
||||
emitSelectedLeaf(node, including: predicate, into: &output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a selected element's start tag and returns the steps to push for its
|
||||
/// subtree: its children to visit, preceded by a close step (so the close pops
|
||||
/// after them). An omitted element renders nothing and pushes no close, but its
|
||||
/// declarations still stay in scope for descendants, so a selected descendant
|
||||
/// re-declares them itself.
|
||||
private func selectedElementSteps(
|
||||
_ node: PureXML.Model.TreeNode,
|
||||
inScope: [String: String],
|
||||
rendered: [String: String],
|
||||
including predicate: (PureXML.Model.TreeNode) -> Bool,
|
||||
output: inout String,
|
||||
) {
|
||||
switch node.kind {
|
||||
case .document:
|
||||
for child in node.children {
|
||||
emitSelected(child, inScope: inScope, rendered: rendered, including: predicate, output: &output)
|
||||
}
|
||||
case .element:
|
||||
emitSelected(element: node, inScope: inScope, rendered: rendered, including: predicate, output: &output)
|
||||
case .text, .cdata:
|
||||
if predicate(node) { output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value) }
|
||||
case .comment:
|
||||
if predicate(node), options.includeComments { output += "<!--\(node.value)-->" }
|
||||
case .processingInstruction:
|
||||
if predicate(node) { output += node.value.isEmpty ? "<?\(node.name?.description ?? "")?>" : "<?\(node.name?.description ?? "") \(node.value)?>" }
|
||||
case .doctype, .entityReference, .namespace:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func emitSelected(
|
||||
element node: PureXML.Model.TreeNode,
|
||||
inScope: [String: String],
|
||||
rendered: [String: String],
|
||||
including predicate: (PureXML.Model.TreeNode) -> Bool,
|
||||
output: inout String,
|
||||
) {
|
||||
into output: inout String,
|
||||
) -> [CanonicalSelectedStep] {
|
||||
let element = PureXML.Model.Element(name: node.name ?? .init(""), attributes: node.attributes, children: [])
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in Self.namespaceDeclarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
guard predicate(node) else {
|
||||
// Omitted element: its namespaces stay in scope for descendants, but
|
||||
// nothing is rendered, so a selected descendant renders them itself.
|
||||
for child in node.children {
|
||||
emitSelected(child, inScope: childInScope, rendered: rendered, including: predicate, output: &output)
|
||||
}
|
||||
return
|
||||
return node.children.reversed().map { .open($0, inScope: childInScope, rendered: rendered) }
|
||||
}
|
||||
let attributes = Self.plainAttributes(element)
|
||||
let toRender = selectedNamespaces(element, inScope: childInScope, attributes: attributes, rendered: rendered)
|
||||
@ -310,10 +315,24 @@ public extension PureXML.Canonical.Canonicalizer {
|
||||
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
|
||||
}
|
||||
output += ">"
|
||||
for child in node.children {
|
||||
emitSelected(child, inScope: childInScope, rendered: childRendered, including: predicate, output: &output)
|
||||
var steps: [CanonicalSelectedStep] = [.close(element.name.description)]
|
||||
steps.append(contentsOf: node.children.reversed().map { .open($0, inScope: childInScope, rendered: childRendered) })
|
||||
return steps
|
||||
}
|
||||
|
||||
/// Emits a selected non-element node (text, CDATA, comment, or PI).
|
||||
private func emitSelectedLeaf(_ node: PureXML.Model.TreeNode, including predicate: (PureXML.Model.TreeNode) -> Bool, into output: inout String) {
|
||||
guard predicate(node) else { return }
|
||||
switch node.kind {
|
||||
case .text, .cdata:
|
||||
output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value)
|
||||
case .comment:
|
||||
if options.includeComments { output += "<!--\(node.value)-->" }
|
||||
case .processingInstruction:
|
||||
output += node.value.isEmpty ? "<?\(node.name?.description ?? "")?>" : "<?\(node.name?.description ?? "") \(node.value)?>"
|
||||
case .element, .document, .doctype, .entityReference, .namespace:
|
||||
break
|
||||
}
|
||||
output += "</\(element.name.description)>"
|
||||
}
|
||||
|
||||
/// The namespaces a selected element renders: in inclusive mode every in-scope
|
||||
|
||||
45
Tests/CanonicalDepthSafetyTests.swift
Normal file
45
Tests/CanonicalDepthSafetyTests.swift
Normal file
@ -0,0 +1,45 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// The Canonical XML transforms that were still recursive must not overflow on a
|
||||
/// deeply-nested tree (#350): the C14N 2.0 sequential prefix rewrite (its
|
||||
/// document-order assignment pass and its rebuild pass) and the node-subset
|
||||
/// serializer. Both now drive explicit work stacks; these reach depths the
|
||||
/// recursive forms could not survive. The main inclusive serializer is covered by
|
||||
/// `TransformDepthSafetyTests`.
|
||||
@Suite("Canonical depth safety")
|
||||
struct CanonicalDepthSafetyTests {
|
||||
private let namespace = "urn:t"
|
||||
|
||||
/// A `depth`-deep chain of namespaced `x:a` elements, the outermost declaring
|
||||
/// the namespace, with a text leaf at the bottom.
|
||||
private func deepNamespacedDocument(_ depth: Int) -> PureXML.Model.Node {
|
||||
let name = PureXML.Model.QualifiedName(prefix: "x", localName: "a", namespaceURI: namespace)
|
||||
var node: PureXML.Model.Node = .element(.init(name: name, attributes: [], children: [.text("leaf")]))
|
||||
for index in 1 ..< depth {
|
||||
let attributes: [PureXML.Model.Attribute] = index == depth - 1 ? [.init("xmlns:x", namespace)] : []
|
||||
node = .element(.init(name: name, attributes: attributes, children: [node]))
|
||||
}
|
||||
return .document([node])
|
||||
}
|
||||
|
||||
@Test("the C14N 2.0 sequential prefix rewrite is depth-safe")
|
||||
func test_deepPrefixRewrite() {
|
||||
let depth = 50000
|
||||
let options = PureXML.Canonical.Options(prefixRewrite: .sequential)
|
||||
let canonical = PureXML.Canonical.canonicalize(deepNamespacedDocument(depth), options: options)
|
||||
// Every element is renamed to the single canonical prefix n0, declared once.
|
||||
#expect(canonical.contains("leaf"))
|
||||
#expect(canonical.contains("xmlns:n0=\"\(namespace)\""))
|
||||
#expect(canonical.components(separatedBy: "</n0:a>").count - 1 == depth)
|
||||
}
|
||||
|
||||
@Test("the node-subset canonicalizer is depth-safe")
|
||||
func test_deepNodeSubset() {
|
||||
let depth = 50000
|
||||
let document = PureXML.Model.TreeNode(deepNamespacedDocument(depth))
|
||||
let canonical = PureXML.Canonical.Canonicalizer(options: .inclusive).canonicalize(document) { _ in true }
|
||||
#expect(canonical.contains("leaf"))
|
||||
#expect(canonical.components(separatedBy: "</x:a>").count - 1 == depth)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user