PureXML/Tests/XIncludeDepthSafetyTests.swift
Mihaela Mihaljevic f4982219a6
perf(xinclude): make XInclude processing depth-safe (#354)
`PureXML.XInclude.process` rebuilt the tree recursively (`process →
processElement → process`), so a deeply-nested document overflowed the stack
one native frame per element level, even with no includes present. It now
drives an explicit work stack: each branch gathers its processed children into
a reference accumulator, then a deferred build step assembles the node. The
`xml:base`, inclusion-chain depth (bounded by `maxDepth`), and cycle-detection
visited set thread down per context 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 all pass. A 50000-deep document,
with or without an include at its base, now processes without crashing, guarded
by `XIncludeDepthSafetyTests`. Part of #350.
2026-06-27 16:55:03 +02:00

70 lines
3.0 KiB
Swift

import Testing
@testable import PureXML
/// XInclude processing must not recurse on a tree's depth (#350). The pass
/// rebuilt the tree recursively, so a deeply-nested document overflowed the
/// stack (as shallow as a couple of thousand deep on a Swift Task stack), even
/// with no includes present. It now drives an explicit work stack; these tests
/// reach depths the recursive form could not survive, for both a plain tree and
/// a deep included resource.
@Suite("XInclude depth safety")
struct XIncludeDepthSafetyTests {
private func deepDocument(_ depth: Int, root: String, leaf: String) -> PureXML.Model.Node {
var node: PureXML.Model.Node = .text(leaf)
for _ in 0 ..< depth {
node = .element(.init("a", children: [node]))
}
return .document([.element(.init(root, children: [node]))])
}
/// The nesting depth of the first-child chain and the deepest text leaf.
private func descend(_ node: PureXML.Model.Node) -> (depth: Int, leaf: String?) {
var current = node
var depth = 0
while true {
switch current {
case let .document(children):
guard let first = children.first else { return (depth, nil) }
current = first
case let .element(element):
guard let first = element.children.first else { return (depth, nil) }
depth += 1
current = first
case let .text(value), let .cdata(value):
return (depth, value)
default:
return (depth, nil)
}
}
}
@Test("processing a 50k-deep document with no includes does not overflow")
func test_deepPlainTreeIsBounded() throws {
let document = deepDocument(50000, root: "doc", leaf: "leaf")
let result = try PureXML.XInclude.process(document, loadingURI: { _ in nil })
// root <doc> plus 50000 <a>, with the leaf preserved.
let descended = descend(result)
#expect(descended.depth == 50001)
#expect(descended.leaf == "leaf")
}
@Test("an include at the bottom of a deep tree resolves without overflowing")
func test_includeDeepInTreeIsBounded() throws {
// A 50k-deep chain whose innermost child is an xi:include, so the include
// is resolved at the bottom of the iterative walk.
let depth = 50000
var node: PureXML.Model.Node = .element(.init("xi:include", attributes: [.init("href", "x.xml")]))
for _ in 0 ..< depth {
node = .element(.init("a", children: [node]))
}
let document = PureXML.Model.Node.document([.element(.init("doc", children: [node]))])
let result = try PureXML.XInclude.process(document, loadingURI: { uri in
uri == "x.xml" ? "<x>hi</x>" : nil
})
let descended = descend(result)
// <doc> -> 50000 <a> -> spliced <x> -> "hi".
#expect(descended.depth == depth + 2)
#expect(descended.leaf == "hi")
}
}