diff --git a/CHANGELOG.md b/CHANGELOG.md index fdee6ee..b686359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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) + - The value-returning XPath result API (`PureXML.xpath`, `XPath.Query.evaluate`/`elements`/`strings`) is now linear for results that nest within one another, not quadratic. Each result was rebuilt as an independent value `Node` subtree, so selecting a deep chain of nested elements materialized the sum of their subtrees. The projection now shares a per-result-set memo keyed by tree-node identity, so a result reuses the already-built value `Node` of a deeper one by reference (copy-on-write); each tree node is projected once. `//a` over a 8000-deep chain drops from about 19 s to about 0.04 s, and the result set is unchanged. (#348) - Sorting an XPath result into document order is now linear in the document, not quadratic in its depth. Each node's order key was its root path, an array as long as the node's depth, so ordering the descendants of a deeply-nested document was O(n log n x depth). The key is now a single O(1) index from a per-root pre-order numbering (built once per document and shared through the evaluation cache), with a band ordering an element before its namespace and attribute nodes and its children. A node-set spanning several documents is grouped by root. This linearizes the reference query path (`Query.nodes`, which XSLT and Schematron drive) on deep documents; the underlying subtree walk (`appendSubtree`) is now iterative too, so it no longer recurses. Document order is unchanged. diff --git a/Sources/Canonical/CanonicalNodeSubset.swift b/Sources/Canonical/CanonicalNodeSubset.swift new file mode 100644 index 0000000..d9dd412 --- /dev/null +++ b/Sources/Canonical/CanonicalNodeSubset.swift @@ -0,0 +1,70 @@ +extension PureXML.Canonical.Canonicalizer { + /// The apex element with its inherited `xml:*` attributes added, and (for + /// inclusive mode) the namespaces in scope from omitted ancestors merged in + /// as declarations so the apex renders its full namespace context. + static func augmentedApex( + _ apex: PureXML.Model.Element, + inheritedXML: [PureXML.Model.Attribute], + mergingNamespaces ancestorNamespaces: [String: String]?, + stripApexBase: Bool, + ) -> PureXML.Model.Element { + // Under 1.1, the apex's own xml:base is folded into the merged base in + // inheritedXML, so drop the original to avoid emitting it twice. + let own = stripApexBase + ? apex.attributes.filter { !($0.name.prefix == "xml" && $0.name.localName == "base") } + : apex.attributes + var attributes = own + inheritedXML + if let ancestorNamespaces { + let declared = Set(apex.attributes.map(\.name.description)) + for (prefix, uri) in ancestorNamespaces { + let name = prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)" + if !declared.contains(name) { attributes.append(PureXML.Model.Attribute(name, uri)) } + } + } + return PureXML.Model.Element(name: apex.name, attributes: attributes, children: apex.children) + } + + /// The namespace declarations in scope from a node's omitted ancestors, + /// nearest ancestor winning (so an inner redeclaration is preserved). + static func inScopeNamespaces(above node: PureXML.Model.TreeNode) -> [String: String] { + var result: [String: String] = [:] + var current = node.parent + while let ancestor = current { + for attribute in ancestor.attributes { + guard let prefix = declaredPrefix(of: attribute.name), result[prefix] == nil else { continue } + result[prefix] = attribute.value + } + current = ancestor.parent + } + return result + } + + /// The `xml:*` attributes in scope from a node's omitted ancestors that the + /// apex does not already set, nearest ancestor winning. + static func inheritedXMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, mergeBase: Bool) -> [PureXML.Model.Attribute] { + let present = Set(apex.attributes.map(\.name.description)) + if mergeBase { return inherited11XMLAttributes(above: node, apex: apex, present: present) } + // Canonical XML 1.0: the nearest of every in-scope xml:* attribute the + // apex does not already set. + var seen: Set = [] + var result: [PureXML.Model.Attribute] = [] + var current = node.parent + while let ancestor = current { + for attribute in ancestor.attributes where attribute.name.prefix == "xml" { + let key = attribute.name.description + guard !present.contains(key), seen.insert(key).inserted else { continue } + result.append(attribute) + } + current = ancestor.parent + } + return result + } + + /// The prefix a namespace declaration binds (empty for the default + /// namespace), or nil when the attribute is not a namespace declaration. + static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? { + if name.prefix == nil, name.localName == "xmlns" { return "" } + if name.prefix == "xmlns" { return name.localName } + return nil + } +} diff --git a/Sources/Canonical/CanonicalStep.swift b/Sources/Canonical/CanonicalStep.swift new file mode 100644 index 0000000..cdffba6 --- /dev/null +++ b/Sources/Canonical/CanonicalStep.swift @@ -0,0 +1,6 @@ +/// One step of the iterative canonical serialization: a node to emit with its +/// in-scope and already-rendered namespace context, or a deferred element close. +enum CanonicalStep { + case open(PureXML.Model.Node, inScope: [String: String], rendered: [String: String]) + case close(String) +} diff --git a/Sources/Canonical/Canonicalizer.swift b/Sources/Canonical/Canonicalizer.swift index eab3445..89e4a46 100644 --- a/Sources/Canonical/Canonicalizer.swift +++ b/Sources/Canonical/Canonicalizer.swift @@ -89,6 +89,35 @@ public extension PureXML.Canonical { rendered: [String: String], output: inout String, ) { + // Deferred-close work stack so a deeply-nested element does not + // overflow the stack; children push reversed to emit in document order. + var stack: [CanonicalStep] = [.open(.element(element), inScope: inScope, rendered: rendered)] + while let step = stack.popLast() { + switch step { + case let .close(name): + output += "" + case let .open(node, inScope, rendered): + guard case let .element(element) = node else { + emitLeaf(node, into: &output) + continue + } + let child = emitOpenTag(element, inScope: inScope, rendered: rendered, into: &output) + stack.append(.close(element.name.description)) + for childNode in element.children.reversed() { + stack.append(.open(childNode, inScope: child.inScope, rendered: child.rendered)) + } + } + } + } + + /// Emits an element's start tag (namespaces then attributes, in canonical + /// order) and returns the namespace context its children inherit. + private func emitOpenTag( + _ element: PureXML.Model.Element, + inScope: [String: String], + rendered: [String: String], + into output: inout String, + ) -> (inScope: [String: String], rendered: [String: String]) { let declarations = Self.namespaceDeclarations(element) var childInScope = inScope for (prefix, uri) in declarations { @@ -109,10 +138,23 @@ public extension PureXML.Canonical { output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\"" } output += ">" - for child in element.children { - emit(child, inScope: childInScope, rendered: childRendered, output: &output) + return (childInScope, childRendered) + } + + /// Emits a non-element node (text, CDATA, comment, or processing + /// instruction) the same way the node-level `emit` does. + private func emitLeaf(_ node: PureXML.Model.Node, into output: inout String) { + switch node { + case let .text(value), let .cdata(value): + let text = options.trimTextNodes ? value.trimmingXMLWhitespace() : value + output += Self.escapeText(text) + case let .comment(value): + if options.includeComments { output += "" } + case let .processingInstruction(target, data): + output += data.isEmpty ? "" : "" + case .element, .document: + break } - output += "" } // MARK: Namespace selection @@ -192,77 +234,6 @@ public extension PureXML.Canonical { return name.prefix != "xmlns" && !(name.prefix == nil && name.localName == "xmlns") } } - - // MARK: Node-subset context - - /// The apex element with its inherited `xml:*` attributes added, and (for - /// inclusive mode) the namespaces in scope from omitted ancestors merged in - /// as declarations so the apex renders its full namespace context. - private static func augmentedApex( - _ apex: PureXML.Model.Element, - inheritedXML: [PureXML.Model.Attribute], - mergingNamespaces ancestorNamespaces: [String: String]?, - stripApexBase: Bool, - ) -> PureXML.Model.Element { - // Under 1.1, the apex's own xml:base is folded into the merged base in - // inheritedXML, so drop the original to avoid emitting it twice. - let own = stripApexBase - ? apex.attributes.filter { !($0.name.prefix == "xml" && $0.name.localName == "base") } - : apex.attributes - var attributes = own + inheritedXML - if let ancestorNamespaces { - let declared = Set(apex.attributes.map(\.name.description)) - for (prefix, uri) in ancestorNamespaces { - let name = prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)" - if !declared.contains(name) { attributes.append(PureXML.Model.Attribute(name, uri)) } - } - } - return PureXML.Model.Element(name: apex.name, attributes: attributes, children: apex.children) - } - - /// The namespace declarations in scope from a node's omitted ancestors, - /// nearest ancestor winning (so an inner redeclaration is preserved). - private static func inScopeNamespaces(above node: PureXML.Model.TreeNode) -> [String: String] { - var result: [String: String] = [:] - var current = node.parent - while let ancestor = current { - for attribute in ancestor.attributes { - guard let prefix = declaredPrefix(of: attribute.name), result[prefix] == nil else { continue } - result[prefix] = attribute.value - } - current = ancestor.parent - } - return result - } - - /// The `xml:*` attributes in scope from a node's omitted ancestors that the - /// apex does not already set, nearest ancestor winning. - private static func inheritedXMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, mergeBase: Bool) -> [PureXML.Model.Attribute] { - let present = Set(apex.attributes.map(\.name.description)) - if mergeBase { return inherited11XMLAttributes(above: node, apex: apex, present: present) } - // Canonical XML 1.0: the nearest of every in-scope xml:* attribute the - // apex does not already set. - var seen: Set = [] - var result: [PureXML.Model.Attribute] = [] - var current = node.parent - while let ancestor = current { - for attribute in ancestor.attributes where attribute.name.prefix == "xml" { - let key = attribute.name.description - guard !present.contains(key), seen.insert(key).inserted else { continue } - result.append(attribute) - } - current = ancestor.parent - } - return result - } - - /// The prefix a namespace declaration binds (empty for the default - /// namespace), or nil when the attribute is not a namespace declaration. - private static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? { - if name.prefix == nil, name.localName == "xmlns" { return "" } - if name.prefix == "xmlns" { return name.localName } - return nil - } } } diff --git a/Sources/Model/NodeRebuild.swift b/Sources/Model/NodeRebuild.swift new file mode 100644 index 0000000..d91748d --- /dev/null +++ b/Sources/Model/NodeRebuild.swift @@ -0,0 +1,64 @@ +extension PureXML.Model.Node { + /// Rebuilds this tree bottom-up without recursing on the tree's depth, so a + /// transform over a deeply-nested document cannot overflow the stack. Each + /// node is passed to `transform` once its children have been rebuilt, together + /// with those rebuilt children (empty for a leaf); the transform returns the + /// replacement node. An explicit work stack drives a post-order walk, so a + /// parent is transformed only after all its descendants. + /// + /// This is the shared spine for the tree-to-tree transforms (DTD default + /// application, canonical prefix rewriting, and the like) that previously + /// recursed one native frame per level. + func rebuildingBottomUp( + _ transform: (_ node: PureXML.Model.Node, _ rebuiltChildren: [PureXML.Model.Node]) -> PureXML.Model.Node, + ) -> PureXML.Model.Node { + guard let rootChildren = Self.branchChildren(self) else { + return transform(self, []) + } + let rootFrame = RebuildFrame(self, rootChildren) + var stack: [RebuildFrame] = [rootFrame] + while let frame = stack.last { + guard frame.next < frame.children.count else { + stack.removeLast() + let rebuilt = transform(frame.node, frame.built) + if let parent = stack.last { + parent.built.append(rebuilt) + } else { + return rebuilt + } + continue + } + let child = frame.children[frame.next] + frame.next += 1 + if let grandchildren = Self.branchChildren(child) { + stack.append(RebuildFrame(child, grandchildren)) + } else { + frame.built.append(transform(child, [])) + } + } + return transform(self, []) + } + + /// The children of a branch node (document or element), or nil for a leaf. + private static func branchChildren(_ node: PureXML.Model.Node) -> [PureXML.Model.Node]? { + switch node { + case let .document(children): children + case let .element(element): element.children + default: nil + } + } +} + +/// A node being rebuilt bottom-up: its source children, the cursor into them, and +/// the rebuilt children gathered so far. +private final class RebuildFrame { + let node: PureXML.Model.Node + let children: [PureXML.Model.Node] + var next = 0 + var built: [PureXML.Model.Node] = [] + + init(_ node: PureXML.Model.Node, _ children: [PureXML.Model.Node]) { + self.node = node + self.children = children + } +} diff --git a/Sources/Validation/DTDDefaults.swift b/Sources/Validation/DTDDefaults.swift index 6de2ed2..e1c9d62 100644 --- a/Sources/Validation/DTDDefaults.swift +++ b/Sources/Validation/DTDDefaults.swift @@ -5,19 +5,28 @@ public extension PureXML.Validation.DTDSchema { /// values are the normalized defaults from the declaration, so an editor reads /// the same effective attributes a validating processor would see. func applyingDefaults(to node: PureXML.Model.Node) -> PureXML.Model.Node { - switch node { - case let .document(children): - .document(children.map(applyingDefaults(to:))) - case let .element(element): - .element(applyingDefaults(to: element)) - case .text, .cdata, .comment, .processingInstruction: - node + // Rebuilt bottom-up (iterative) so a deeply-nested document does not + // overflow the stack; each element gains its DTD-declared defaults once its + // children are rebuilt. + node.rebuildingBottomUp { original, rebuiltChildren in + switch original { + case let .element(element): + var result = element + result.children = rebuiltChildren + return .element(withDefaults(result)) + case .document: + return .document(rebuiltChildren) + default: + return original + } } } - private func applyingDefaults(to element: PureXML.Model.Element) -> PureXML.Model.Element { + /// Adds the attributes `element`'s `` declares with a default or + /// `#FIXED` value and that the element omits. `result.children` is assumed + /// already set by the caller. + private func withDefaults(_ element: PureXML.Model.Element) -> PureXML.Model.Element { var result = element - result.children = element.children.map { applyingDefaults(to: $0) } guard let declarations = attributes[element.name.description] else { return result } for declaration in declarations { guard let value = Self.defaultValue(declaration) else { continue } diff --git a/Tests/TransformDepthSafetyTests.swift b/Tests/TransformDepthSafetyTests.swift new file mode 100644 index 0000000..4a4e400 --- /dev/null +++ b/Tests/TransformDepthSafetyTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import PureXML + +/// Tree-to-tree transforms must not recurse on a document's depth (#350). They +/// rebuilt the tree with a per-node recursive walk, so transforming a deeply +/// nested document overflowed the stack (as shallow as a couple of thousand deep +/// on a Swift Task stack, which these tests run on). The walks are now iterative. +@Suite("Transform depth safety") +struct TransformDepthSafetyTests { + private func deepXML(_ depth: Int, doctype: String = "") -> String { + doctype + String(repeating: "", count: depth) + "x" + String(repeating: "", count: depth) + } + + @Test("applying DTD attribute defaults to a deep document does not overflow") + func test_deepDTDDefaults() throws { + let depth = 50000 + let xml = deepXML(depth, doctype: "]>") + let node = try PureXML.parseApplyingInternalDTDDefaults(xml, limits: .init(maxDepth: depth + 1, allowDoctype: true)) + // Every `a` element gained the defaulted `kept="on"` attribute; check the + // outermost one without recursing. + guard case let .document(roots) = node, let root = roots.first(where: { $0.element != nil })?.element else { + Issue.record("expected a document with a root element") + return + } + #expect(root.attributes.contains { $0.name.localName == "kept" && $0.value == "on" }) + } + + @Test("canonicalizing a deep document does not overflow") + func test_deepCanonicalize() throws { + let depth = 50000 + let document = try PureXML.parse(deepXML(depth), limits: .init(maxDepth: depth + 1)) + let canonical = PureXML.Canonical.Canonicalizer(options: .inclusive).canonicalize(document) + // Each level contributes ""; the leaf text and the closes round it out. + #expect(canonical.hasPrefix("")) + #expect(canonical.hasSuffix("")) + #expect(canonical.contains("x")) + } +}