`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.
250 lines
12 KiB
Swift
250 lines
12 KiB
Swift
/// One XInclude pass over a value tree. File-scope and private: an internal
|
|
/// detail of ``PureXML/XInclude``. Operates on the immutable ``PureXML/Model/Node``
|
|
/// tree, returning a new tree with `xi:include` elements replaced.
|
|
/// The in-scope context threaded down an XInclude pass: the `xml:base` for
|
|
/// resolving hrefs, the inclusion-chain `depth` (bounded by `maxDepth`), and the
|
|
/// `visited` resources that detect a cycle.
|
|
private struct XIncludeContext {
|
|
let base: String
|
|
let depth: Int
|
|
let visited: Set<String>
|
|
}
|
|
|
|
/// A mutable bag of processed nodes a branch's children gather into, so the
|
|
/// deferred build step assembles the parent once they are done.
|
|
private final class XIncludeAccumulator {
|
|
var nodes: [PureXML.Model.Node] = []
|
|
}
|
|
|
|
/// One unit of XInclude work: visit a source node (appending its processed result
|
|
/// to `target`), or, after a branch's children are gathered, build the element or
|
|
/// document and append it to `target`. File-scoped to keep the work stack off the
|
|
/// type-nesting depth.
|
|
private enum XIncludeWork {
|
|
case visit(PureXML.Model.Node, XIncludeContext, XIncludeAccumulator)
|
|
case buildElement(PureXML.Model.Element, XIncludeAccumulator, XIncludeAccumulator)
|
|
case buildDocument(XIncludeAccumulator, XIncludeAccumulator)
|
|
}
|
|
|
|
private struct XIncludeRun {
|
|
typealias Node = PureXML.Model.Node
|
|
typealias Element = PureXML.Model.Element
|
|
|
|
typealias Request = PureXML.XInclude.XIncludeRequest
|
|
|
|
let load: (Request) -> String?
|
|
let maxDepth = 30
|
|
let namespace = "http://www.w3.org/2001/XInclude"
|
|
|
|
/// Rebuilds the tree with `xi:include` elements replaced, without recursing on
|
|
/// the tree's depth: an explicit work stack drives a post-order rebuild so a
|
|
/// deeply-nested document does not overflow the stack. Each branch gathers its
|
|
/// processed children into a reference accumulator, then a deferred build step
|
|
/// assembles the node once those children are done. `base` (the in-scope
|
|
/// `xml:base`) threads down per element; `depth` (the inclusion-chain depth,
|
|
/// bounded by `maxDepth`) and `visited` (the cycle set) advance only across an
|
|
/// include boundary, exactly as the recursive form did.
|
|
func process(_ root: Node, base: String) throws -> [Node] {
|
|
let rootResult = XIncludeAccumulator()
|
|
var stack: [XIncludeWork] = [.visit(root, XIncludeContext(base: base, depth: 0, visited: []), rootResult)]
|
|
while let work = stack.popLast() {
|
|
switch work {
|
|
case let .visit(node, context, target):
|
|
try visit(node, context, into: target, stack: &stack)
|
|
case let .buildElement(element, children, target):
|
|
target.nodes.append(.element(Element(name: element.name, attributes: element.attributes, children: children.nodes)))
|
|
case let .buildDocument(children, target):
|
|
target.nodes.append(.document(children.nodes))
|
|
}
|
|
}
|
|
return rootResult.nodes
|
|
}
|
|
|
|
/// Processes one node into `target`. A branch pushes its build step first, then
|
|
/// its children reversed, so the children pop in document order and the build
|
|
/// runs after them.
|
|
private func visit(_ node: Node, _ context: XIncludeContext, into target: XIncludeAccumulator, stack: inout [XIncludeWork]) throws {
|
|
switch node {
|
|
case let .document(children):
|
|
let gathered = XIncludeAccumulator()
|
|
stack.append(.buildDocument(gathered, target))
|
|
for child in children.reversed() {
|
|
stack.append(.visit(child, context, gathered))
|
|
}
|
|
case let .element(element):
|
|
if isInclude(element) {
|
|
try resolveInclude(element, context, into: target, stack: &stack)
|
|
} else {
|
|
let elementBase = baseURI(of: element, base: context.base)
|
|
let childContext = XIncludeContext(base: elementBase, depth: context.depth, visited: context.visited)
|
|
let gathered = XIncludeAccumulator()
|
|
stack.append(.buildElement(element, gathered, target))
|
|
for child in element.children.reversed() {
|
|
stack.append(.visit(child, childContext, gathered))
|
|
}
|
|
}
|
|
default:
|
|
target.nodes.append(node)
|
|
}
|
|
}
|
|
|
|
/// Replaces an `xi:include` with its target, pushing the included (or fallback)
|
|
/// content onto the stack to be processed into `target` in place. The included
|
|
/// content advances the inclusion chain (`depth + 1`, base reset to the
|
|
/// resource, the resource added to `visited`); fallback content stays in the
|
|
/// include's own context, matching the recursive form.
|
|
private func resolveInclude(_ element: Element, _ context: XIncludeContext, into target: XIncludeAccumulator, stack: inout [XIncludeWork]) throws {
|
|
guard context.depth < maxDepth else { throw PureXML.XInclude.XIncludeError.toodeep }
|
|
let elementBase = baseURI(of: element, base: context.base)
|
|
guard let href = attribute(element, "href") else {
|
|
try pushFallback(element, context, into: target, error: .missingHref, stack: &stack)
|
|
return
|
|
}
|
|
let resolved = PureXML.XInclude.URIReference.resolve(href, against: elementBase)
|
|
let isText = attribute(element, "parse") == "text"
|
|
let xpointer = attribute(element, "xpointer")
|
|
if isText, xpointer != nil { throw PureXML.XInclude.XIncludeError.textWithFragment }
|
|
// A resource already on its own inclusion chain is a cycle.
|
|
guard !context.visited.contains(resolved) else { throw PureXML.XInclude.XIncludeError.cycle(resolved) }
|
|
guard let content = load(request(element, uri: resolved, isText: isText)) else {
|
|
try pushFallback(element, context, into: target, error: .unresolved(href), stack: &stack)
|
|
return
|
|
}
|
|
if isText {
|
|
target.nodes.append(.text(content))
|
|
return
|
|
}
|
|
guard let parsed = try? PureXML.parse(content) else {
|
|
try pushFallback(element, context, into: target, error: .unresolved(href), stack: &stack)
|
|
return
|
|
}
|
|
let selected = selectedNodes(from: parsed, xpointer: xpointer)
|
|
let includedContext = XIncludeContext(base: resolved, depth: context.depth + 1, visited: context.visited.union([resolved]))
|
|
for node in selected.reversed() {
|
|
stack.append(.visit(node, includedContext, target))
|
|
}
|
|
}
|
|
|
|
private func request(_ element: Element, uri: String, isText: Bool) -> Request {
|
|
Request(
|
|
uri: uri,
|
|
accept: attribute(element, "accept"),
|
|
acceptLanguage: attribute(element, "accept-language"),
|
|
encoding: isText ? attribute(element, "encoding") : nil,
|
|
isText: isText,
|
|
)
|
|
}
|
|
|
|
private func selectedNodes(from parsed: Node, xpointer: String?) -> [Node] {
|
|
guard let xpointer else { return [documentElement(of: parsed) ?? parsed] }
|
|
let selections = (try? PureXML.XPointer.evaluate(xpointer, over: parsed)) ?? []
|
|
if !selections.isEmpty { return selections.map(Self.node) }
|
|
// No node-selecting scheme matched: fall back to the XPointer range model
|
|
// (range()/range-to()/string-range()) and include each range's content.
|
|
let ranges = (try? PureXML.XPointer.evaluateRanges(xpointer, over: parsed)) ?? []
|
|
return ranges.flatMap(\.nodes)
|
|
}
|
|
|
|
private func documentElement(of parsed: Node) -> Node? {
|
|
guard case let .document(children) = parsed else { return nil }
|
|
return children.first { if case .element = $0 { true } else { false } }
|
|
}
|
|
|
|
private static func node(_ selection: PureXML.XPath.Selection) -> Node {
|
|
switch selection {
|
|
case let .node(node): node
|
|
case let .attribute(attribute): .text(attribute.value)
|
|
}
|
|
}
|
|
|
|
private func pushFallback(
|
|
_ element: Element,
|
|
_ context: XIncludeContext,
|
|
into target: XIncludeAccumulator,
|
|
error: PureXML.XInclude.XIncludeError,
|
|
stack: inout [XIncludeWork],
|
|
) throws {
|
|
guard let fallback = element.children.first(where: isFallback),
|
|
case let .element(fallbackElement) = fallback
|
|
else {
|
|
throw error
|
|
}
|
|
for child in fallbackElement.children.reversed() {
|
|
stack.append(.visit(child, context, target))
|
|
}
|
|
}
|
|
|
|
private func baseURI(of element: Element, base: String) -> String {
|
|
guard let declared = attribute(element, "xml:base") else { return base }
|
|
return PureXML.XInclude.URIReference.resolve(declared, against: base)
|
|
}
|
|
|
|
private func isInclude(_ element: Element) -> Bool {
|
|
element.name.localName == "include" && inXIncludeNamespace(element)
|
|
}
|
|
|
|
private func isFallback(_ node: Node) -> Bool {
|
|
guard case let .element(element) = node else { return false }
|
|
return element.name.localName == "fallback" && inXIncludeNamespace(element)
|
|
}
|
|
|
|
private func inXIncludeNamespace(_ element: Element) -> Bool {
|
|
element.name.namespaceURI == namespace || element.name.prefix == "xi"
|
|
}
|
|
|
|
private func attribute(_ element: Element, _ name: String) -> String? {
|
|
element.attributes.first { $0.name.description == name }?.value
|
|
}
|
|
}
|
|
|
|
public extension PureXML.XInclude {
|
|
/// Processes the `xi:include` elements in a parsed tree, returning a new tree
|
|
/// with each include replaced by its target's content. `href`s are resolved
|
|
/// against `base` and the in-scope `xml:base`, then loaded through
|
|
/// `loadingURI`. PureXML performs no I/O itself: a reference whose loader
|
|
/// returns nil falls back to the include's `xi:fallback`, or errors if there
|
|
/// is none. With a loader that always returns nil nothing external is fetched.
|
|
static func process(
|
|
_ node: PureXML.Model.Node,
|
|
base: String = "",
|
|
loadingURI: @escaping (_ uri: String) -> String?,
|
|
) throws -> PureXML.Model.Node {
|
|
try process(node, base: base) { loadingURI($0.uri) }
|
|
}
|
|
|
|
/// Parses `xml` and processes its `xi:include` elements.
|
|
static func process(
|
|
_ xml: String,
|
|
base: String = "",
|
|
loadingURI: @escaping (_ uri: String) -> String?,
|
|
) throws -> PureXML.Model.Node {
|
|
try process(PureXML.parse(xml), base: base, loadingURI: loadingURI)
|
|
}
|
|
|
|
/// Processes `xi:include` elements with a content-negotiation-aware loader: the
|
|
/// loader receives an ``XIncludeRequest`` carrying the resolved URI plus the
|
|
/// include's `accept`, `accept-language`, and (for `parse="text"`) `encoding`
|
|
/// hints, and returns the loaded text or nil to fall back. A resource included
|
|
/// along its own inclusion chain raises ``XIncludeError/cycle(_:)``; a
|
|
/// `parse="text"` include carrying an `xpointer` raises
|
|
/// ``XIncludeError/textWithFragment``.
|
|
static func process(
|
|
_ node: PureXML.Model.Node,
|
|
base: String = "",
|
|
loading: @escaping (_ request: XIncludeRequest) -> String?,
|
|
) throws -> PureXML.Model.Node {
|
|
let run = XIncludeRun(load: loading)
|
|
return try run.process(node, base: base).first ?? node
|
|
}
|
|
|
|
/// Parses `xml` and processes its `xi:include` elements with a
|
|
/// content-negotiation-aware loader. See ``process(_:base:loading:)-(Node,_,_)``.
|
|
static func process(
|
|
_ xml: String,
|
|
base: String = "",
|
|
loading: @escaping (_ request: XIncludeRequest) -> String?,
|
|
) throws -> PureXML.Model.Node {
|
|
try process(PureXML.parse(xml), base: base, loading: loading)
|
|
}
|
|
}
|