diff --git a/CHANGELOG.md b/CHANGELOG.md index a16b177..7c78544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Sources/Canonical/CanonicalPrefixRewrite.swift b/Sources/Canonical/CanonicalPrefixRewrite.swift index 23af01a..be5e0d8 100644 --- a/Sources/Canonical/CanonicalPrefixRewrite.swift +++ b/Sources/Canonical/CanonicalPrefixRewrite.swift @@ -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 diff --git a/Sources/Canonical/CanonicalSelectedStep.swift b/Sources/Canonical/CanonicalSelectedStep.swift new file mode 100644 index 0000000..9e4c25a --- /dev/null +++ b/Sources/Canonical/CanonicalSelectedStep.swift @@ -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) +} diff --git a/Sources/Canonical/Canonicalizer.swift b/Sources/Canonical/Canonicalizer.swift index 89e4a46..a284927 100644 --- a/Sources/Canonical/Canonicalizer.swift +++ b/Sources/Canonical/Canonicalizer.swift @@ -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 += "" + 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 += "" } - case .processingInstruction: - if predicate(node) { output += node.value.isEmpty ? "" : "" } - 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 += "" } + case .processingInstruction: + output += node.value.isEmpty ? "" : "" + case .element, .document, .doctype, .entityReference, .namespace: + break } - output += "" } /// The namespaces a selected element renders: in inclusive mode every in-scope diff --git a/Tests/CanonicalDepthSafetyTests.swift b/Tests/CanonicalDepthSafetyTests.swift new file mode 100644 index 0000000..b8d7885 --- /dev/null +++ b/Tests/CanonicalDepthSafetyTests.swift @@ -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: "").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: "").count - 1 == depth) + } +}