diff --git a/CHANGELOG.md b/CHANGELOG.md index 751ac3f..49c3fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- An XSLT transform with unbounded template recursion no longer overflows the stack; it fails gracefully with `XSLTError.recursionLimitExceeded` instead (#356). A recursive named template whose depth is driven by source data (for example ``) could recurse without bound and crash the process (SIGBUS) on an attacker-controllable input, the way every XSLT processor guards against (libxslt's `xsltMaxDepth`). Template-instantiation nesting is now counted and capped at `maxTemplateDepth` (default 300, exposed as `PureXML.XSLT.defaultMaxTemplateDepth`), a new optional parameter on `PureXML.XSLT.transform`. The default clears the parser's maximum source nesting depth (256) so an identity transform of the deepest permitted source still runs, while staying within an 8 MB main-thread stack with margin; a stylesheet with deeper legitimate recursion can raise the limit (on a correspondingly larger stack). Measurement note: source *nesting* depth was never the exposure here, since the engine only ever sees a source parsed at the default `maxDepth` and an identity transform of a 254-deep source completes normally; the reachable crash was data-driven template recursion. The full XSLT/XPath conformance suite is unchanged (no corpus transform recurses past the default). Making the engine fully iterative (so deep recursion succeeds rather than failing gracefully, and is safe on a small Swift-concurrency Task stack) remains tracked in #356. +- The XSLT evaluator is now iterative, so template recursion and result-tree nesting no longer consume the native stack (#356). A recursive named template whose depth is driven by source data (for example ``) could previously recurse without bound and crash the process (SIGBUS) on attacker-controllable input; and even an identity transform of a deeply-nested source overflowed the stack on a small (Swift-concurrency Task) stack. The mutually-recursive `instantiate`/`evaluate`/`applyTemplates` engine is replaced by an explicit work-stack driver (`XSLTDriver`): each instruction appends its produced items to a sink or pushes sub-work, with the rest of the body pushed first so document order is preserved; template application, the built-in rule's source descent, and result-element nesting all run on the heap. Only stylesheet-bounded helper evaluation (a variable's or parameter's result-tree fragment, an attribute set, an `xsl:message` body) re-enters through a nested driver call, whose native depth is bounded by the stylesheet's own nesting. The result-tree serialization walks the engine now exposes at depth (the namespace fixup, the `text` and string-value extractions, and the content-type-meta and CDATA-section rewrites) were made iterative as well. Deep recursion now *succeeds* on any stack (a 5000-level recursive template builds and serializes on the test Task stack); `maxTemplateDepth` is retained as runaway protection (an unbounded result tree would otherwise exhaust memory), now defaulting to 40000 and tripping `XSLTError.recursionLimitExceeded` only past that. Behavior is otherwise unchanged: the full Apache Xalan XSLT 1.0 conformance corpus and the in-repo XSLT/XPath suites pass identically (one previously-failing Xalan case, `mdocs07`, now passes and was de-baselined). `maxTemplateDepth` and `PureXML.XSLT.defaultMaxTemplateDepth` (added for the earlier stack-based guard) keep their public shape. - Serializing a tree as HTML (`PureXML.HTML.serialize`, the html output method's serializer) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested tree (as shallow as a couple of thousand deep on a Swift Task stack). The serializer now drives a deferred-close work stack (the same shape as the XML serializer and `Canonicalizer`): an element pushes a close step then its children reversed, and a raw-text element (`script`, `style`) flags its direct text children to emit verbatim. The output is byte-identical: the void-element, raw-text, comment, and escaping semantics are unchanged, with the HTML conformance corpus passing. A 50000-deep tree now serializes without crashing. (#350) diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index b79abbf..1cf229d 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -70,82 +70,6 @@ extension PureXML.XSLT.Transformer { } return name } - - func elementInstruction(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [PureXML.XSLT.ResultItem] { - guard case let .element(nameTemplate, namespaceTemplate, namespaces, useAttributeSets, body) = instruction else { - return [] - } - let raw = avt(nameTemplate, context) - let parts = raw.split(separator: ":", omittingEmptySubsequences: false) - let hasExplicitNamespace = (namespaceTemplate.map { !avt($0, context).isEmpty }) ?? false - let prefix = parts.count == 2 ? String(parts[0]) : nil - // A prefix not bound to a namespace (and no explicit namespace attribute - // supplies one) makes the QName unusable, as does a non-NCName part. - let undeclaredPrefix = !hasExplicitNamespace && (prefix.map { $0 != "xml" && namespaces[$0] == nil } ?? false) - let unusableName = raw.isEmpty || parts.contains(where: \.isEmpty) || parts.count > 2 - || !parts.allSatisfy { PureXML.Parsing.XMLCharacter.isValidName(String($0)) } || undeclaredPrefix - if unusableName { - // The recovery (7.1.2, element-name-not-QName) emits the content - // without the wrapper element. - return instantiate(body, context).filter { if case .attribute = $0 { false } else { true } } - } - let name = createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: false) - return [buildElement(name: name, literalAttributes: [], useAttributeSets: useAttributeSets, body: body, context)] - } - - func attributeInstruction( - _ nameTemplate: PureXML.XSLT.ValueTemplate, - _ namespaceTemplate: PureXML.XSLT.ValueTemplate?, - _ namespaces: [String: String], - _ body: [PureXML.XSLT.Instruction], - _ context: XSLTContext, - ) -> [PureXML.XSLT.ResultItem] { - let name = createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: true) - // xsl:attribute content that creates a non-text node ignores it with its - // content (XSLT 1.0 errata E27), like xsl:comment/processing-instruction. - return [.attribute(.init(name: name, value: escapedTextValue(of: instantiate(body, context))))] - } - - func copyInstruction(_ useAttributeSets: [String], _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [PureXML.XSLT.ResultItem] { - // A non-tree current node copies itself: an attribute node yields an - // attribute result, a namespace node its declaration. - if let current = context.current { - switch current { - case let .attribute(_, attribute): - return [.attribute(attribute)] - case let .namespace(_, prefix, uri): - return [.attribute(.init(prefix.isEmpty ? "xmlns" : "xmlns:" + prefix, uri))] - case .tree: - break - } - } - switch context.node.kind { - case .element: - let copied = buildElement( - name: context.node.name ?? .init(""), - literalAttributes: Self.namespaceDeclarations(inScopeAt: context.node), - useAttributeSets: useAttributeSets, - body: body, - context, - ) - return [copied] - case .text, .cdata: - return [.node(.text(context.node.value))] - case .comment: - return [.node(.comment(context.node.value))] - case .processingInstruction: - return [.node(.processingInstruction(target: context.node.name?.description ?? "", data: context.node.value))] - case .document: - // Copying the root node produces no element of its own, but xsl:copy - // may carry use-attribute-sets (7.5); those attributes have no copied - // element to attach to, so they join the enclosing result element, - // ahead of the copied content. - let setAttributes = attributeSetAttributes(useAttributeSets, context, visiting: []).map(PureXML.XSLT.ResultItem.attribute) - return setAttributes + instantiate(body, context) - default: - return instantiate(body, context) - } - } } extension PureXML.XSLT.Transformer { @@ -256,30 +180,3 @@ extension PureXML.XSLT.Transformer { return XSLTNumbering.format(numbers, format, grouping) } } - -extension PureXML.XSLT.Transformer { - /// A literal result element: aliased name and attributes, the copied - /// 7.1.1 namespace declarations, then the shared element builder. - func literalResult(_ instruction: PureXML.XSLT.Instruction, _ context: PureXML.XSLT.XSLTContext) -> PureXML.XSLT.ResultItem { - guard case let .literalElement(name, attributes, namespaces, useAttributeSets, body) = instruction else { - return .node(.text("")) - } - let aliasedAttributes = attributes.map { PureXML.XSLT.LiteralAttribute(name: aliased($0.name), value: $0.value) } - // The copied namespace nodes (7.1.1) travel as xmlns attributes; the - // fixup pass reuses them and drops the ones already in scope. An - // aliased stylesheet namespace declares its result namespace instead. - var declarations: [PureXML.XSLT.LiteralAttribute] = [] - for (prefix, uri) in namespaces.sorted(by: { $0.key < $1.key }) { - let alias = stylesheet.namespaceAliases[uri] - // Keep the literal prefix; an alias only remaps its namespace URI. - let resolvedPrefix = prefix.isEmpty ? nil : prefix - let resolvedURI = alias?.uri ?? uri - let attributeName = resolvedPrefix.map { "xmlns:" + $0 } ?? "xmlns" - declarations.append(PureXML.XSLT.LiteralAttribute( - name: PureXML.Model.QualifiedName(attributeName), - value: [.literal(resolvedURI)], - )) - } - return buildElement(name: aliased(name), literalAttributes: declarations + aliasedAttributes, useAttributeSets: useAttributeSets, body: body, context) - } -} diff --git a/Sources/XSLT/XSLTInstantiation.swift b/Sources/XSLT/XSLTInstantiation.swift new file mode 100644 index 0000000..25af4ee --- /dev/null +++ b/Sources/XSLT/XSLTInstantiation.swift @@ -0,0 +1,130 @@ +// The XSLT sequence-constructor evaluator, driven by an explicit work stack so +// template recursion and result-tree nesting do not consume the native stack. +// A recursive named template, an identity transform of a deeply-nested source, +// or any deep result tree is evaluated iteratively here; only stylesheet-bounded +// helper evaluation (a variable's or parameter's result-tree fragment, an +// attribute set, an xsl:message body) re-enters through a nested driver call, +// whose depth is bounded by the stylesheet's own nesting. +// +// A work item's `depth` is the logical template-application nesting (one per +// apply-templates / call-template / built-in-rule descent), not a native frame +// count. `maxTemplateDepth` caps it as runaway protection (an unbounded result +// tree would otherwise exhaust memory), failing gracefully via `recursionGuard`. + +extension PureXML.XSLT.Transformer { + /// Instantiates a sequence constructor, iteratively. + func instantiate(_ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [ResultItem] { + XSLTDriver(self).run(body, context) + } + + /// Applies templates to `nodes` (already selected and sorted), iteratively. + func applyTemplates( + to nodes: [PureXML.XPath.Node], + mode: String?, + parameters: [PureXML.XSLT.Binding], + _ context: XSLTContext, + ) -> [ResultItem] { + XSLTDriver(self).applyTemplates(nodes, mode: mode, parameters: parameters, context) + } +} + +/// The iterative evaluator. One per top-level `instantiate`/`applyTemplates` +/// call; nested helper evaluation makes its own. Holds the work stack so the step +/// methods need not thread it as a parameter. +final class XSLTDriver { + typealias Host = PureXML.XSLT.Transformer + typealias Instruction = PureXML.XSLT.Instruction + typealias Binding = PureXML.XSLT.Binding + + /// A mutable, ordered bag of produced items a branch's work appends into; a + /// deferred `finish` step assembles the parent node from it. + final class Accumulator { + var items: [ResultItem] = [] + } + + /// Where produced items go and at what logical recursion depth. + struct Sink { + let depth: Int + let into: Accumulator + } + + /// The shared context of one template application across its node-set: the + /// mode, the parameters passed, the caller's context, and the sink. + struct Application { + let mode: String? + let parameters: [Binding] + let caller: XSLTContext + let sink: Sink + } + + /// How a completed child accumulator is turned into a node appended to a parent. + enum Finish { + /// Partition the child items into attributes (before the first node) and + /// children, then wrap them in an element with the precomputed name and + /// base attributes (attribute sets plus literal attributes). + case element(PureXML.Model.QualifiedName, [PureXML.Model.Attribute]) + /// The `xsl:element` recovery for an unusable name: emit the content with + /// no wrapper, dropping attribute items (XSLT 1.0 7.1.2). + case filteredElement + case comment + case processingInstruction(String) + case attribute(PureXML.Model.QualifiedName) + } + + /// One unit of evaluation work. + enum Work { + /// Run `body` from `cursor` in `context`, appending to the sink. + case run([Instruction], Int, XSLTContext, Sink) + /// Apply templates to already-selected, already-sorted `nodes`. + case applyTemplates([PureXML.XPath.Node], Application) + /// Apply templates to one node (its position and size fixed). Each node is + /// its own deferred work item so a leaf node's text and a nested node's + /// template output land in the sink in document order. + case applyOne(PureXML.XPath.Node, Int, Int, Application) + /// Assemble a node from a finished child accumulator into a parent. + case finish(Finish, Accumulator, Accumulator) + } + + let transformer: Host + var stack: [Work] = [] + + init(_ transformer: Host) { + self.transformer = transformer + } + + func run(_ body: [Instruction], _ context: XSLTContext) -> [ResultItem] { + let root = Accumulator() + stack = [.run(body, 0, context, Sink(depth: 0, into: root))] + drive() + return root.items + } + + func applyTemplates( + _ nodes: [PureXML.XPath.Node], + mode: String?, + parameters: [Binding], + _ context: XSLTContext, + ) -> [ResultItem] { + let root = Accumulator() + let application = Application(mode: mode, parameters: parameters, caller: context, sink: Sink(depth: 0, into: root)) + stack = [.applyTemplates(nodes, application)] + drive() + return root.items + } + + private func drive() { + while let work = stack.popLast() { + if transformer.termination.message != nil || transformer.recursionGuard.exceeded { continue } + switch work { + case let .run(body, cursor, context, sink): + stepRun(body, cursor, context, sink) + case let .applyTemplates(nodes, application): + stepApplyTemplates(nodes, application) + case let .applyOne(xnode, position, size, application): + stepApplyOne(xnode, position, size, application) + case let .finish(kind, child, into): + stepFinish(kind, child, into) + } + } + } +} diff --git a/Sources/XSLT/XSLTInstantiationElements.swift b/Sources/XSLT/XSLTInstantiationElements.swift new file mode 100644 index 0000000..082d801 --- /dev/null +++ b/Sources/XSLT/XSLTInstantiationElements.swift @@ -0,0 +1,221 @@ +// Element construction, template application, and node assembly for the iterative +// XSLT evaluator (see XSLTInstantiation.swift). + +extension XSLTDriver { + // MARK: Element construction + + /// The attributes an element carries before its body content: attribute sets + /// (lowest precedence) then literal attributes. + private func elementBaseAttributes( + _ literalAttributes: [PureXML.XSLT.LiteralAttribute], + _ useAttributeSets: [String], + _ context: XSLTContext, + ) -> [PureXML.Model.Attribute] { + var attributes = transformer.attributeSetAttributes(useAttributeSets, context, visiting: []) + attributes += literalAttributes.map { PureXML.Model.Attribute(name: $0.name, value: transformer.avt($0.value, context)) } + return attributes + } + + /// Pushes a finished element and its body, so the body fills a child + /// accumulator that the finish step then wraps. + private func pushElementBody(_ finish: Finish, _ body: [Instruction], _ context: XSLTContext, _ sink: Sink) { + let child = Accumulator() + stack.append(.finish(finish, child, sink.into)) + stack.append(.run(body, 0, context, Sink(depth: sink.depth, into: child))) + } + + func pushLiteralElement(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) { + guard case let .literalElement(name, attributes, namespaces, useAttributeSets, body) = instruction else { return } + let aliasedAttributes = attributes.map { PureXML.XSLT.LiteralAttribute(name: transformer.aliased($0.name), value: $0.value) } + // The copied namespace nodes (7.1.1) travel as xmlns attributes; an + // aliased stylesheet namespace declares its result namespace instead. + var declarations: [PureXML.XSLT.LiteralAttribute] = [] + for (prefix, uri) in namespaces.sorted(by: { $0.key < $1.key }) { + let alias = transformer.stylesheet.namespaceAliases[uri] + let resolvedPrefix = prefix.isEmpty ? nil : prefix + let resolvedURI = alias?.uri ?? uri + let attributeName = resolvedPrefix.map { "xmlns:" + $0 } ?? "xmlns" + declarations.append(PureXML.XSLT.LiteralAttribute(name: PureXML.Model.QualifiedName(attributeName), value: [.literal(resolvedURI)])) + } + let base = elementBaseAttributes(declarations + aliasedAttributes, useAttributeSets, context) + pushElementBody(.element(transformer.aliased(name), base), body, context, sink) + } + + func pushElement(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) { + guard case let .element(nameTemplate, namespaceTemplate, namespaces, useAttributeSets, body) = instruction else { return } + let raw = transformer.avt(nameTemplate, context) + let parts = raw.split(separator: ":", omittingEmptySubsequences: false) + let hasExplicitNamespace = (namespaceTemplate.map { !transformer.avt($0, context).isEmpty }) ?? false + let prefix = parts.count == 2 ? String(parts[0]) : nil + let undeclaredPrefix = !hasExplicitNamespace && (prefix.map { $0 != "xml" && namespaces[$0] == nil } ?? false) + let unusableName = raw.isEmpty || parts.contains(where: \.isEmpty) || parts.count > 2 + || !parts.allSatisfy { PureXML.Parsing.XMLCharacter.isValidName(String($0)) } || undeclaredPrefix + if unusableName { + // The recovery emits the content without the wrapper element. + pushElementBody(.filteredElement, body, context, sink) + return + } + let name = transformer.createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: false) + let base = elementBaseAttributes([], useAttributeSets, context) + pushElementBody(.element(name, base), body, context, sink) + } + + func pushCopy(_ useAttributeSets: [String], _ body: [Instruction], _ context: XSLTContext, _ sink: Sink) { + // A non-tree current node copies itself. + if let current = context.current { + switch current { + case let .attribute(_, attribute): + sink.into.items.append(.attribute(attribute)) + return + case let .namespace(_, prefix, uri): + sink.into.items.append(.attribute(.init(prefix.isEmpty ? "xmlns" : "xmlns:" + prefix, uri))) + return + case .tree: + break + } + } + switch context.node.kind { + case .element: + let base = elementBaseAttributes(Host.namespaceDeclarations(inScopeAt: context.node), useAttributeSets, context) + pushElementBody(.element(context.node.name ?? .init(""), base), body, context, sink) + case .text, .cdata: + sink.into.items.append(.node(.text(context.node.value))) + case .comment: + sink.into.items.append(.node(.comment(context.node.value))) + case .processingInstruction: + sink.into.items.append(.node(.processingInstruction(target: context.node.name?.description ?? "", data: context.node.value))) + case .document: + // The root copies to no element; use-attribute-sets join the enclosing + // result element ahead of the copied content (7.5). + let setAttributes = transformer.attributeSetAttributes(useAttributeSets, context, visiting: []).map(ResultItem.attribute) + sink.into.items.append(contentsOf: setAttributes) + stack.append(.run(body, 0, context, sink)) + default: + stack.append(.run(body, 0, context, sink)) + } + } + + // MARK: Template application + + func stepApplyTemplates(_ nodes: [PureXML.XPath.Node], _ application: Application) { + // Reversed so the per-node work pops in document order; each node's own + // sub-work then completes before the next node's pops. + let size = nodes.count + for (offset, xnode) in nodes.enumerated().reversed() { + stack.append(.applyOne(xnode, offset + 1, size, application)) + } + } + + func stepApplyOne(_ xnode: PureXML.XPath.Node, _ position: Int, _ size: Int, _ application: Application) { + guard let owner = Host.ownerNode(xnode) else { return } + let nodeContext = XSLTContext( + node: owner, + current: xnode.treeNode == nil ? xnode : nil, + position: position, + size: size, + variables: application.caller.variables, + mode: application.mode, + ) + if let template = transformer.bestTemplate(for: xnode, mode: application.mode) { + pushTemplateBody(template, nodeContext, application.parameters, application.caller, application.sink) + } else { + pushBuiltInRule(xnode, application.mode, nodeContext, application.sink) + } + } + + /// Binds a template's parameters and schedules its body (one deeper). + func pushTemplateBody( + _ template: PureXML.XSLT.Template, + _ context: XSLTContext, + _ parameters: [Binding], + _ caller: XSLTContext, + _ sink: Sink, + ) { + let depth = sink.depth + 1 + guard depth <= transformer.maxTemplateDepth else { + transformer.recursionGuard.exceeded = true + return + } + var context = context + context.importPrecedence = template.importPrecedence + context.importRangeLow = template.importRangeLow + context.namespaces = template.namespaces + context.baseURI = template.baseURI + for parameter in template.parameters { + if let passed = parameters.first(where: { $0.name == parameter.name }) { + context.variables[parameter.name] = transformer.variableValue(passed.select, passed.body, caller) + } else { + context.variables[parameter.name] = transformer.variableValue(parameter.select, parameter.body, context) + } + } + stack.append(.run(template.body, 0, context, Sink(depth: depth, into: sink.into))) + } + + func pushBuiltInRule(_ xnode: PureXML.XPath.Node, _ mode: String?, _ context: XSLTContext, _ sink: Sink) { + let depth = sink.depth + 1 + guard depth <= transformer.maxTemplateDepth else { + transformer.recursionGuard.exceeded = true + return + } + switch xnode { + case let .tree(node): + switch node.kind { + case .element, .document: + let application = Application(mode: mode, parameters: [], caller: context, sink: Sink(depth: depth, into: sink.into)) + stack.append(.applyTemplates(node.children.map { .tree($0) }, application)) + case .text, .cdata: + sink.into.items.append(.node(.text(node.value))) + default: + break + } + case let .attribute(_, attribute): + sink.into.items.append(.node(.text(attribute.value))) + case let .namespace(_, _, uri): + sink.into.items.append(.node(.text(uri))) + } + } + + func pushCallTemplate(_ name: String, _ parameters: [Binding], _ context: XSLTContext, _ sink: Sink) { + // XSLT 1.0 section 6: the highest import-precedence definition wins. + guard let template = transformer.stylesheet.templates.filter({ $0.name == name }) + .max(by: { $0.importPrecedence < $1.importPrecedence }) else { return } + pushTemplateBody(template, context, parameters, context, sink) + } + + func pushApplyImports(_ context: XSLTContext, _ sink: Sink) { + if let template = transformer.bestTemplate(for: context.focus, mode: context.mode, below: context.importPrecedence, atLeast: context.importRangeLow) { + pushTemplateBody(template, context, [], context, sink) + } else { + pushBuiltInRule(context.focus, context.mode, context, sink) + } + } + + // MARK: Finishing a node + + func stepFinish(_ kind: Finish, _ child: Accumulator, _ into: Accumulator) { + switch kind { + case let .element(name, baseAttributes): + var attributes = baseAttributes + var children: [PureXML.Model.Node] = [] + for item in child.items { + switch item { + case let .attribute(attribute): + // Ignored once content has been added (the recovery for an + // attribute created after children). + if children.isEmpty { attributes.append(attribute) } + case let .node(node): + children.append(node) + } + } + into.items.append(.node(.element(.init(name: name, attributes: Host.deduplicated(attributes), children: children)))) + case .filteredElement: + into.items.append(contentsOf: child.items.filter { if case .attribute = $0 { false } else { true } }) + case .comment: + into.items.append(.node(.comment(transformer.escapedTextValue(of: child.items)))) + case let .processingInstruction(target): + into.items.append(.node(.processingInstruction(target: target, data: transformer.escapedTextValue(of: child.items)))) + case let .attribute(name): + into.items.append(.attribute(.init(name: name, value: transformer.escapedTextValue(of: child.items)))) + } + } +} diff --git a/Sources/XSLT/XSLTInstantiationSteps.swift b/Sources/XSLT/XSLTInstantiationSteps.swift new file mode 100644 index 0000000..daa60ba --- /dev/null +++ b/Sources/XSLT/XSLTInstantiationSteps.swift @@ -0,0 +1,131 @@ +// The step methods of the iterative XSLT evaluator (see XSLTInstantiation.swift). +// Each instruction either appends its produced items to the sink synchronously or +// pushes sub-work; the rest of the body is always pushed first so it pops last, +// keeping result-tree document order. + +extension XSLTDriver { + func stepRun(_ body: [Instruction], _ cursor: Int, _ context: XSLTContext, _ sink: Sink) { + guard cursor < body.count else { return } + let instruction = body[cursor] + switch instruction { + case let .variable(name, select, variableBody): + // A variable binds for the rest of the body; thread the updated context. + var context = context + context.variables[name] = transformer.variableValue(select, variableBody, context) + stack.append(.run(body, cursor + 1, context, sink)) + return + case let .fallback(fallbackBody): + stack.append(.run(body, cursor + 1, context, sink)) + stack.append(.run(fallbackBody, 0, context, sink)) + return + default: + break + } + // The remainder of the body runs after this instruction's items. + stack.append(.run(body, cursor + 1, context, sink)) + if runSimple(instruction, context, sink) { return } + if runControl(instruction, context, sink) { return } + if runTemplate(instruction, context, sink) { return } + runConstruct(instruction, context, sink) + } + + private func runSimple(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) -> Bool { + switch instruction { + case let .literalText(text): + sink.into.items.append(.node(.text(text))) + case let .valueOf(select, raw): + let value = transformer.string(select, context) + sink.into.items.append(.node(.text(raw ? PureXML.XSLT.RawText.marked(value) : value))) + case .number: + sink.into.items.append(.node(.text(transformer.numberInstruction(instruction, context)))) + case let .copyOf(select): + sink.into.items.append(contentsOf: transformer.copyOf(select, context)) + case let .message(terminate, messageBody): + if terminate, transformer.termination.message == nil { + transformer.termination.message = Host.text(of: transformer.instantiate(messageBody, context)) + } + default: + return false + } + return true + } + + private func runControl(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) -> Bool { + switch instruction { + case let .ifInstruction(test, thenBody): + if transformer.boolean(test, context) { stack.append(.run(thenBody, 0, context, sink)) } + case let .choose(whens, otherwise): + let branch = whens.first { transformer.boolean($0.test, context) }?.body ?? otherwise + stack.append(.run(branch, 0, context, sink)) + case let .forEach(select, sorts, eachBody): + pushForEach(select, sorts, eachBody, context, sink) + default: + return false + } + return true + } + + private func runTemplate(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) -> Bool { + switch instruction { + case let .applyTemplates(select, mode, sorts, parameters): + let nodes = transformer.sorted(transformer.selectXPathNodes(select ?? "node()", context), sorts, context) + stack.append(.applyTemplates(nodes, Application(mode: mode, parameters: parameters, caller: context, sink: sink))) + case let .callTemplate(name, parameters): + pushCallTemplate(name, parameters, context, sink) + case .applyImports: + pushApplyImports(context, sink) + default: + return false + } + return true + } + + private func runConstruct(_ instruction: Instruction, _ context: XSLTContext, _ sink: Sink) { + switch instruction { + case .literalElement: + pushLiteralElement(instruction, context, sink) + case .element: + pushElement(instruction, context, sink) + case let .attribute(nameTemplate, namespaceTemplate, namespaces, attributeBody): + let name = transformer.createdName(nameTemplate, namespaceTemplate, namespaces, context, isAttribute: true) + pushTextNode(.attribute(name), attributeBody, context, sink) + case let .copy(useAttributeSets, copyBody): + pushCopy(useAttributeSets, copyBody, context, sink) + case let .comment(commentBody): + pushTextNode(.comment, commentBody, context, sink) + case let .processingInstruction(nameTemplate, piBody): + pushTextNode(.processingInstruction(transformer.avt(nameTemplate, context)), piBody, context, sink) + default: + break + } + } + + private func pushForEach( + _ select: String, + _ sorts: [PureXML.XSLT.Sort], + _ body: [Instruction], + _ context: XSLTContext, + _ sink: Sink, + ) { + let nodes = transformer.sorted(transformer.selectXPathNodes(select, context), sorts, context) + // Reversed so the nodes' bodies pop in document order into the sink. + for (offset, xnode) in nodes.enumerated().reversed() { + guard let owner = Host.ownerNode(xnode) else { continue } + var itemContext = XSLTContext( + node: owner, + current: xnode.treeNode == nil ? xnode : nil, + position: offset + 1, + size: nodes.count, + variables: context.variables, + ) + itemContext.baseURI = context.baseURI + stack.append(.run(body, 0, itemContext, sink)) + } + } + + private func pushTextNode(_ kind: Finish, _ body: [Instruction], _ context: XSLTContext, _ sink: Sink) { + let child = Accumulator() + stack.append(.finish(kind, child, sink.into)) + stack.append(.run(body, 0, context, Sink(depth: sink.depth, into: child))) + } +} diff --git a/Sources/XSLT/XSLTNamespaceFixup.swift b/Sources/XSLT/XSLTNamespaceFixup.swift index 7b2d343..d4816bc 100644 --- a/Sources/XSLT/XSLTNamespaceFixup.swift +++ b/Sources/XSLT/XSLTNamespaceFixup.swift @@ -1,11 +1,61 @@ +/// A mutable bag of fixed-up nodes a branch's children gather into, so the +/// deferred build step assembles the parent once they are done. +private final class FixupAccumulator { + var nodes: [PureXML.Model.Node] = [] +} + +/// One unit of namespace-fixup work: visit a source node (with the namespace +/// bindings in scope, appending its fixed form to `into`), or, after a branch's +/// children are gathered, build the element or document. +private enum FixupWork { + case visit(PureXML.Model.Node, inScope: [String: String], into: FixupAccumulator) + case buildElement(PureXML.Model.QualifiedName, [PureXML.Model.Attribute], FixupAccumulator, FixupAccumulator) + case buildDocument(FixupAccumulator, FixupAccumulator) +} + /// Namespace fixup over an XSLT result tree (the serialization step of /// XSLT 1.0 section 7.1): every element and attribute name that carries a /// namespace URI gets an in-scope declaration, with `ns0`, `ns1`, ... prefixes /// generated when the carried prefix is absent or bound to a different URI. enum XSLTNamespaceFixup { + private static let xmlNamespace = "http://www.w3.org/XML/1998/namespace" + + /// Rebuilds the tree with namespace declarations fixed, without recursing on + /// its depth: an explicit work stack drives a pre-order visit (so the `ns0`, + /// `ns1`, ... counter advances in document order, exactly as the recursive form + /// did) that gathers each branch's fixed children into an accumulator a + /// deferred build step assembles. static func apply(_ node: PureXML.Model.Node) -> PureXML.Model.Node { var counter = 0 - return fix(node, inScope: ["xml": "http://www.w3.org/XML/1998/namespace"], counter: &counter) + let root = FixupAccumulator() + var stack: [FixupWork] = [.visit(node, inScope: ["xml": xmlNamespace], into: root)] + while let work = stack.popLast() { + switch work { + case let .visit(node, inScope, into): + switch node { + case let .document(children): + let gathered = FixupAccumulator() + stack.append(.buildDocument(gathered, into)) + for child in children.reversed() { + stack.append(.visit(child, inScope: inScope, into: gathered)) + } + case let .element(element): + let (attributes, childScope) = fixElement(element, inScope: inScope, counter: &counter) + let gathered = FixupAccumulator() + stack.append(.buildElement(element.name, attributes, gathered, into)) + for child in element.children.reversed() { + stack.append(.visit(child, inScope: childScope, into: gathered)) + } + default: + into.nodes.append(node) + } + case let .buildElement(name, attributes, gathered, into): + into.nodes.append(.element(.init(name: name, attributes: attributes, children: gathered.nodes))) + case let .buildDocument(gathered, into): + into.nodes.append(.document(gathered.nodes)) + } + } + return root.nodes.first ?? node } /// Declarations the element already carries enter scope first. @@ -34,17 +84,6 @@ enum XSLTNamespaceFixup { } } - private static func fix(_ node: PureXML.Model.Node, inScope: [String: String], counter: inout Int) -> PureXML.Model.Node { - switch node { - case let .document(children): - .document(children.map { fix($0, inScope: inScope, counter: &counter) }) - case let .element(element): - .element(fix(element, inScope: inScope, counter: &counter)) - default: - node - } - } - /// Drops declarations that repeat an inherited binding: copied 7.1.1 /// namespace nodes travel on every literal element and would otherwise /// redeclare on each descendant. @@ -60,7 +99,14 @@ enum XSLTNamespaceFixup { } } - private static func fix(_ element: PureXML.Model.Element, inScope: [String: String], counter: inout Int) -> PureXML.Model.Element { + /// Fixes an element's own attributes and computes the namespace scope its + /// children inherit (the per-element work of the fixup, without recursing into + /// the children). The `ns0`, `ns1`, ... counter advances here, in visit order. + private static func fixElement( + _ element: PureXML.Model.Element, + inScope: [String: String], + counter: inout Int, + ) -> (attributes: [PureXML.Model.Attribute], scope: [String: String]) { var scope = inScope var attributes = withoutRedundantDeclarations(element.attributes, inScope: inScope) enterDeclarations(attributes, into: &scope) @@ -79,9 +125,8 @@ enum XSLTNamespaceFixup { continue // Already bound correctly. } if let candidate = prefix, !localPrefixes.contains(candidate), element.name.prefix != candidate { - // Redeclare the carried prefix locally, shadowing any - // inherited binding (the source document's own shape). The - // element's own prefix is never shadowed. + // Redeclare the carried prefix locally, shadowing any inherited + // binding. The element's own prefix is never shadowed. localPrefixes.insert(candidate) } else if prefix == nil || scope[prefix ?? ""] != nil { // Generate a fresh prefix when absent or taken locally. @@ -105,7 +150,6 @@ enum XSLTNamespaceFixup { value: attributes[index].value, ) } - let children = element.children.map { fix($0, inScope: scope, counter: &counter) } - return .init(name: element.name, attributes: attributes, children: children) + return (attributes, scope) } } diff --git a/Sources/XSLT/XSLTRuntime.swift b/Sources/XSLT/XSLTRuntime.swift index 1dfbff4..de0cc0a 100644 --- a/Sources/XSLT/XSLTRuntime.swift +++ b/Sources/XSLT/XSLTRuntime.swift @@ -1,11 +1,11 @@ extension PureXML.XSLT { - /// The default ceiling on template-instantiation nesting (see - /// ``RecursionGuard``). Chosen to clear the parser's max source depth (256), - /// so an identity transform of the deepest permitted source still runs, while - /// staying within an 8 MB stack with margin; unbounded template recursion - /// (which can overflow the stack) trips it well before that. Configurable per - /// transform for stylesheets with deeper legitimate recursion. - public static let defaultMaxTemplateDepth = 300 + /// The default ceiling on logical template-application nesting (see + /// ``RecursionGuard``). The evaluator is iterative, so this is no longer a + /// stack limit but a runaway guard: legitimate deep recursion (well into the + /// thousands) succeeds, while an infinite or unbounded recursion, whose result + /// tree would grow without bound, fails gracefully once it passes this depth. + /// Configurable per transform. + public static let defaultMaxTemplateDepth = 40000 /// One produced item: a node for the result tree, or an attribute to attach to /// the enclosing element. @@ -73,14 +73,15 @@ extension PureXML.XSLT { var message: String? } - /// Shared, mutable guard against unbounded template-instantiation recursion - /// (a recursive template whose depth is driven by source data would otherwise - /// overflow the native stack). `depth` tracks the current nesting of template - /// applications; `exceeded` latches once it passes the configured limit, so the - /// instantiation loops unwind and the transform fails gracefully. A reference - /// type so the value-semantics transformer can update it. + /// Shared, mutable latch for runaway template recursion. The evaluator is + /// iterative, so deep recursion no longer overflows the stack; this instead + /// caps the *logical* template-application depth (carried on each work item) + /// so an unbounded or infinite recursion, whose result tree would grow without + /// bound, fails gracefully rather than exhausting memory. `exceeded` latches + /// once the depth passes `maxTemplateDepth`, so the work stack drains and the + /// transform throws. A reference type so the value-semantics transformer can + /// set it. final class RecursionGuard { - var depth = 0 var exceeded = false } } diff --git a/Sources/XSLT/XSLTSorting.swift b/Sources/XSLT/XSLTSorting.swift index 40e3110..2a15f93 100644 --- a/Sources/XSLT/XSLTSorting.swift +++ b/Sources/XSLT/XSLTSorting.swift @@ -87,64 +87,6 @@ extension PureXML.XSLT.Transformer { text.enumerated().compactMap { $1 == " " ? $0 : nil } } - func forEach( - _ select: String, - _ sorts: [PureXML.XSLT.Sort], - _ body: [PureXML.XSLT.Instruction], - _ context: XSLTContext, - ) -> [ResultItem] { - let nodes = sorted(selectXPathNodes(select, context), sorts, context) - var items: [ResultItem] = [] - for (offset, xnode) in nodes.enumerated() { - guard let owner = Self.ownerNode(xnode) else { continue } - var itemContext = XSLTContext( - node: owner, - current: xnode.treeNode == nil ? xnode : nil, - position: offset + 1, - size: nodes.count, - variables: context.variables, - ) - itemContext.baseURI = context.baseURI - items += instantiate(body, itemContext) - } - return items - } - - func chooseInstruction( - _ whens: [PureXML.XSLT.Branch], - _ otherwise: [PureXML.XSLT.Instruction], - _ context: XSLTContext, - ) -> [ResultItem] { - for branch in whens where boolean(branch.test, context) { - return instantiate(branch.body, context) - } - return instantiate(otherwise, context) - } - - func builtInRule(_ xnode: PureXML.XPath.Node, mode: String?, _ context: XSLTContext) -> [ResultItem] { - recursionGuard.depth += 1 - defer { recursionGuard.depth -= 1 } - guard recursionGuard.depth <= maxTemplateDepth else { - recursionGuard.exceeded = true - return [] - } - return switch xnode { - case let .tree(node): - switch node.kind { - case .element, .document: - applyTemplates(to: node.children.map { .tree($0) }, mode: mode, parameters: [], context) - case .text, .cdata: - [.node(.text(node.value))] - default: - [] - } - case let .attribute(_, attribute): - [.node(.text(attribute.value))] - case let .namespace(_, _, uri): - [.node(.text(uri))] - } - } - /// Copies the nodes selected by `select` into the result tree (`xsl:copy-of`); /// a non-node-set result copies as its string value. Whole subtrees are taken /// as-is, so this does not recurse on their depth. @@ -162,40 +104,4 @@ extension PureXML.XSLT.Transformer { } } } - - func message(_ terminate: Bool, _ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [ResultItem] { - if terminate, termination.message == nil { - termination.message = Self.text(of: instantiate(body, context)) - } - return [] - } - - /// Binds the template's parameters (a passed `with-param` wins over the - /// declared default) and instantiates its body. - func instantiateTemplate( - _ template: PureXML.XSLT.Template, - _ context: XSLTContext, - passing parameters: [PureXML.XSLT.Binding], - from caller: XSLTContext, - ) -> [ResultItem] { - recursionGuard.depth += 1 - defer { recursionGuard.depth -= 1 } - guard recursionGuard.depth <= maxTemplateDepth else { - recursionGuard.exceeded = true - return [] - } - var context = context - context.importPrecedence = template.importPrecedence - context.importRangeLow = template.importRangeLow - context.namespaces = template.namespaces - context.baseURI = template.baseURI - for parameter in template.parameters { - if let passed = parameters.first(where: { $0.name == parameter.name }) { - context.variables[parameter.name] = variableValue(passed.select, passed.body, caller) - } else { - context.variables[parameter.name] = variableValue(parameter.select, parameter.body, context) - } - } - return instantiate(template.body, context) - } } diff --git a/Sources/XSLT/XSLTTransform.swift b/Sources/XSLT/XSLTTransform.swift index 2c6955c..73633b8 100644 --- a/Sources/XSLT/XSLTTransform.swift +++ b/Sources/XSLT/XSLTTransform.swift @@ -86,27 +86,31 @@ public extension PureXML.XSLT { /// prepended to the children of the first `head` element (16.2: the html /// output method should add a meta element giving the encoding). private static func withContentTypeMeta(_ node: PureXML.Model.Node, encoding: String) -> PureXML.Model.Node { - switch node { - case let .document(children): - return .document(children.map { withContentTypeMeta($0, encoding: encoding) }) - case let .element(element): - var children = element.children.map { withContentTypeMeta($0, encoding: encoding) } - let needsMeta = element.name.localName.lowercased() == "head" - && !children.contains(where: { isContentTypeMeta($0) }) - if needsMeta { - let meta = PureXML.Model.Element( - name: .init("META"), - attributes: [ - .init("http-equiv", "Content-Type"), - .init("content", "text/html; charset=\(encoding)"), - ], - children: [], - ) - children.insert(.element(meta), at: 0) + // Rebuilt bottom-up through the shared iterative spine so a deeply-nested + // result does not overflow the stack. + node.rebuildingBottomUp { node, rebuiltChildren in + switch node { + case .document: + return .document(rebuiltChildren) + case let .element(element): + var children = rebuiltChildren + let needsMeta = element.name.localName.lowercased() == "head" + && !children.contains(where: { isContentTypeMeta($0) }) + if needsMeta { + let meta = PureXML.Model.Element( + name: .init("META"), + attributes: [ + .init("http-equiv", "Content-Type"), + .init("content", "text/html; charset=\(encoding)"), + ], + children: [], + ) + children.insert(.element(meta), at: 0) + } + return .element(.init(name: element.name, attributes: element.attributes, children: children)) + default: + return node } - return .element(.init(name: element.name, attributes: element.attributes, children: children)) - default: - return node } } @@ -119,18 +123,24 @@ public extension PureXML.XSLT { /// replaced by CDATA nodes, so the serializer emits them as CDATA sections. private static func withCDATASections(_ node: PureXML.Model.Node, _ names: Set) -> PureXML.Model.Node { guard !names.isEmpty else { return node } - switch node { - case let .document(children): - return .document(children.map { withCDATASections($0, names) }) - case let .element(element): - let wrap = names.contains(element.name.localName) || names.contains(element.name.description) - let children = element.children.flatMap { child -> [PureXML.Model.Node] in - if wrap, case let .text(value) = child { return cdataNodes(value) } - return [withCDATASections(child, names)] + // Rebuilt bottom-up through the shared iterative spine; an element named in + // `names` has its own text children re-wrapped as CDATA. + return node.rebuildingBottomUp { node, rebuiltChildren in + switch node { + case .document: + return .document(rebuiltChildren) + case let .element(element): + let wrap = names.contains(element.name.localName) || names.contains(element.name.description) + let children = wrap + ? rebuiltChildren.flatMap { child -> [PureXML.Model.Node] in + if case let .text(value) = child { return cdataNodes(value) } + return [child] + } + : rebuiltChildren + return .element(.init(name: element.name, attributes: element.attributes, children: children)) + default: + return node } - return .element(.init(name: element.name, attributes: element.attributes, children: children)) - default: - return node } } @@ -244,11 +254,23 @@ public extension PureXML.XSLT { /// The concatenated text content of a result tree, for the `text` output /// method: character data only, with markup, comments, and PIs dropped. private static func textValue(of node: PureXML.Model.Node) -> String { - switch node { - case let .text(value), let .cdata(value): value - case let .document(children): children.map(textValue).joined() - case let .element(element): element.children.map(textValue).joined() - case .comment, .processingInstruction: "" + // Iterative pre-order walk so a deeply-nested result tree does not overflow + // the stack; children push reversed to keep document order. Only character + // data contributes; markup, comments, and PIs drop out. + var result = "" + var stack: [PureXML.Model.Node] = [node] + while let current = stack.popLast() { + switch current { + case let .text(value), let .cdata(value): + result += value + case let .document(children): + stack.append(contentsOf: children.reversed()) + case let .element(element): + stack.append(contentsOf: element.children.reversed()) + case .comment, .processingInstruction: + break + } } + return result } } diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index fec9e95..99c92d0 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -104,7 +104,7 @@ extension PureXML.XSLT { return .document(applyTemplates(to: [.tree(root)], mode: nil, parameters: [], context).compactMap(Self.nodeOf)) } - fileprivate func bestTemplate(for node: PureXML.XPath.Node, mode: String?, below ceiling: Int = .max, atLeast floor: Int = .min) -> Template? { + func bestTemplate(for node: PureXML.XPath.Node, mode: String?, below ceiling: Int = .max, atLeast floor: Int = .min) -> Template? { stylesheet.templates.enumerated() .filter { entry in entry.element.mode == mode && entry.element.importPrecedence < ceiling @@ -144,50 +144,6 @@ extension PureXML.XSLT { patternTable } - func applyTemplates( - to nodes: [PureXML.XPath.Node], - mode: String?, - parameters: [Binding], - _ context: XSLTContext, - ) -> [ResultItem] { - var items: [ResultItem] = [] - for (offset, xnode) in nodes.enumerated() { - if termination.message != nil || recursionGuard.exceeded { break } - guard let owner = Self.ownerNode(xnode) else { continue } - let nodeContext = XSLTContext( - node: owner, - current: xnode.treeNode == nil ? xnode : nil, - position: offset + 1, - size: nodes.count, - variables: context.variables, - mode: mode, - ) - if let template = bestTemplate(for: xnode, mode: mode) { - items += instantiateTemplate(template, nodeContext, passing: parameters, from: context) - } else { - items += builtInRule(xnode, mode: mode, nodeContext) - } - } - return items - } - - func instantiate(_ body: [Instruction], _ context: XSLTContext) -> [ResultItem] { - var items: [ResultItem] = [] - var context = context - for instruction in body { - if termination.message != nil || recursionGuard.exceeded { break } - switch instruction { - case let .variable(name, select, varBody): - context.variables[name] = variableValue(select, varBody, context) - case let .fallback(fallbackBody): - items += instantiate(fallbackBody, context) - default: - items += evaluate(instruction, context) - } - } - return items - } - func variableValue(_ select: String?, _ body: [Instruction], _ context: XSLTContext) -> PureXML.XPath.Value { if let select { return value(select, context) ?? .string("") } // XSLT 1.0 11.2: a variable with no select and empty content is the @@ -258,119 +214,28 @@ extension PureXML.XSLT { } private static func stringValue(_ node: PureXML.Model.Node) -> String { - switch node { - case let .text(value), let .cdata(value): value - case let .element(element): element.children.reduce(into: "") { $0 += stringValue($1) } - case let .document(children): children.reduce(into: "") { $0 += stringValue($1) } - default: "" + // Iterative pre-order walk so a deeply-nested result tree does not + // overflow the stack; only text and CDATA contribute. + var result = "" + var stack: [PureXML.Model.Node] = [node] + while let current = stack.popLast() { + switch current { + case let .text(value), let .cdata(value): + result += value + case let .element(element): + stack.append(contentsOf: element.children.reversed()) + case let .document(children): + stack.append(contentsOf: children.reversed()) + default: + break + } } + return result } } } extension PureXML.XSLT.Transformer { - fileprivate func evaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem] { - simpleEvaluate(instruction, context) ?? structuralEvaluate(instruction, context) - } - - private func simpleEvaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem]? { - switch instruction { - case let .literalText(text): [.node(.text(text))] - case let .valueOf(select, raw): - [.node(.text(raw ? PureXML.XSLT.RawText.marked(string(select, context)) : string(select, context)))] - case let .applyTemplates(select, mode, sorts, parameters): - applyTemplates( - to: sorted(selectXPathNodes(select ?? "node()", context), sorts, context), - mode: mode, - parameters: parameters, - context, - ) - case let .forEach(select, sorts, body): forEach(select, sorts, body, context) - case let .ifInstruction(test, body): boolean(test, context) ? instantiate(body, context) : [] - case let .choose(whens, otherwise): chooseInstruction(whens, otherwise, context) - case let .copyOf(select): copyOf(select, context) - case let .callTemplate(name, parameters): callTemplate(name, parameters, context) - case .variable: [] - default: nil - } - } - - // Instantiates an `xsl:message` body as its text; `terminate` records the - // signal so the transform aborts with it. Produces no result-tree output. - - // Builds a literal result element, rewriting its name and attribute names - // through any `xsl:namespace-alias` in effect. - - private func structuralEvaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem] { - switch instruction { - case .literalElement: - [literalResult(instruction, context)] - case .element: - elementInstruction(instruction, context) - case let .attribute(nameTemplate, namespaceTemplate, namespaces, body): - attributeInstruction(nameTemplate, namespaceTemplate, namespaces, body, context) - case let .copy(useAttributeSets, body): - copyInstruction(useAttributeSets, body, context) - case .number: - [.node(.text(numberInstruction(instruction, context)))] - case let .comment(body): - [.node(.comment(escapedTextValue(of: instantiate(body, context))))] - case let .processingInstruction(name, body): - [.node(.processingInstruction(target: avt(name, context), data: escapedTextValue(of: instantiate(body, context))))] - case let .message(terminate, body): - message(terminate, body, context) - case .applyImports: - applyImports(context) - default: - [] - } - } - - /// Re-applies templates to the current node in the current mode, considering - /// only those below the current template's import precedence, falling back to - /// the built-in rule when none match. - private func applyImports(_ context: XSLTContext) -> [ResultItem] { - if let template = bestTemplate(for: context.focus, mode: context.mode, below: context.importPrecedence, atLeast: context.importRangeLow) { - return instantiateTemplate(template, context, passing: [], from: context) - } - return builtInRule(context.focus, mode: context.mode, context) - } - - private func callTemplate(_ name: String, _ parameters: [PureXML.XSLT.Binding], _ context: XSLTContext) -> [ResultItem] { - // XSLT 1.0 section 6: pick the highest import-precedence definition, not the - // first by position, so an included template outranks a same-name import. - guard let template = stylesheet.templates.filter({ $0.name == name }) - .max(by: { $0.importPrecedence < $1.importPrecedence }) else { return [] } - return instantiateTemplate(template, context, passing: parameters, from: context) - } - - // MARK: Building elements - - func buildElement( - name: PureXML.Model.QualifiedName, - literalAttributes: [PureXML.XSLT.LiteralAttribute], - useAttributeSets: [String], - body: [PureXML.XSLT.Instruction], - _ context: XSLTContext, - ) -> ResultItem { - // Attribute sets are lowest precedence, then the element's literal - // attributes, then its xsl:attribute body; a later same-named attribute - // replaces an earlier one. - var attributes = attributeSetAttributes(useAttributeSets, context, visiting: []) - attributes += literalAttributes.map { PureXML.Model.Attribute(name: $0.name, value: avt($0.value, context)) } - var children: [PureXML.Model.Node] = [] - for item in instantiate(body, context) { - switch item { - case let .attribute(attribute): - // Ignored once content has been added (the XSLT recovery for - // an attribute created after children). - if children.isEmpty { attributes.append(attribute) } - case let .node(node): children.append(node) - } - } - return .node(.element(.init(name: name, attributes: Self.deduplicated(attributes), children: children))) - } - /// The attributes contributed by `names` and the attribute sets they include, /// lower precedence first, with a `visiting` guard against recursive includes. func attributeSetAttributes(_ names: [String], _ context: XSLTContext, visiting: Set) -> [PureXML.Model.Attribute] { diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index b0d3398..184ac1d 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -9,7 +9,6 @@ extend/extend04 idkey/idkey31 impincl/impincl08 impincl/impincl27 -mdocs/mdocs07 mdocs/mdocs19 output/output22 output/output26 diff --git a/Tests/XSLTRecursionLimitTests.swift b/Tests/XSLTRecursionLimitTests.swift index d94feb7..5beec08 100644 --- a/Tests/XSLTRecursionLimitTests.swift +++ b/Tests/XSLTRecursionLimitTests.swift @@ -1,96 +1,59 @@ -// The recursion guard must be exercised on a large stack (the limit is above -// what a small stack holds), which needs Foundation's Thread. WASI is -// single-threaded and has no Thread/Dispatch, so these tests are excluded -// there; the guard itself is platform-independent pure Swift, covered by the -// macOS and Linux runs. -#if !os(WASI) - import Foundation - import Testing - @testable import PureXML +import Testing +@testable import PureXML - /// XSLT template recursion is bounded so an unbounded recursive template (whose - /// depth can be driven by source data) fails gracefully instead of overflowing - /// the stack (#356). Source *nesting* depth is already bounded by the parser's - /// `maxDepth`, so it is not the exposure; a recursive named template is. - /// - /// These run on a dedicated large-stack thread: the recursion limit (300) is - /// above what swift-testing's small Task stack holds, so the guard must be given - /// a production-sized stack to fire on rather than the test harness overflowing - /// first. That is exactly the gap the limit closes on a normal 8 MB main thread. - @Suite("XSLT template recursion limit") - struct XSLTRecursionLimitTests { - private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"" +/// The XSLT evaluator is iterative, so template recursion is bounded by heap, not +/// the native stack (#356). Deep recursion that would once have overflowed the +/// stack now succeeds, even here where swift-testing runs on a small Task stack; +/// only a runaway/infinite recursion (whose result tree would grow without bound) +/// fails gracefully at the configurable `maxTemplateDepth`. +@Suite("XSLT template recursion") +struct XSLTRecursionTests { + private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"" - /// Runs `body` on a thread with a large stack and returns its result, so the - /// recursion guard is exercised on a production-sized stack. - private func onLargeStack(_ body: @escaping @Sendable () -> T) -> T { - let box = Box() - let done = DispatchSemaphore(value: 0) - let thread = Thread { - box.value = body() - done.signal() - } - thread.stackSize = 64 * 1024 * 1024 - thread.start() - done.wait() - guard let value = box.value else { preconditionFailure("the worker thread produced no result") } - return value - } + /// A stylesheet whose named template recurses `count` times, driving the + /// recursion from the passed parameter (a stand-in for `count(//x)`). + private func recursiveStylesheet(_ count: Int) -> String { + "" + + "" + + "" + + "" + + "" + + "" + + "" + } - private final class Box: @unchecked Sendable { var value: T? } + @Test("deep recursion succeeds on a small stack instead of overflowing") + func test_deepRecursionSucceeds() throws { + // 5000 levels far exceeds what the recursive engine survived on a Task + // stack (a few hundred); the iterative engine builds it on the heap. + let out = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(5000), source: "") + #expect(out.components(separatedBy: "").count - 1 == 4999) + } - /// A stylesheet whose named template recurses `count` times, driving the - /// recursion from the passed parameter (a stand-in for `count(//x)`). - private func recursiveStylesheet(_ count: Int) -> String { - "" - + "" - + "" - + "" - + "" - + "" - + "" - } - - @Test("recursion past the default limit throws rather than crashing") - func test_deepRecursionFailsGracefully() { - let error: (any Error)? = onLargeStack { - do { - _ = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(5000), source: "") - return nil - } catch { - return error - } - } - #expect(error as? PureXML.XSLT.XSLTError == .recursionLimitExceeded(PureXML.XSLT.defaultMaxTemplateDepth)) - } - - @Test("recursion within the limit transforms normally") - func test_shallowRecursionSucceeds() { - let out = onLargeStack { (try? PureXML.XSLT.transform(stylesheet: recursiveStylesheet(100), source: "")) ?? "" } - #expect(out.contains("")) - } - - @Test("a raised maxTemplateDepth allows deeper legitimate recursion") - func test_configurableLimit() { - let out = onLargeStack { - (try? PureXML.XSLT.transform(stylesheet: recursiveStylesheet(1000), source: "", maxTemplateDepth: 4000)) ?? "" - } - // Recursion went far past the default limit of 300 (it would otherwise have - // thrown), producing ~1000 nested elements. - #expect(out.components(separatedBy: "").count - 1 > 900) - } - - @Test("an identity transform of the deepest permitted source still runs") - func test_identityOfMaxDepthSource() { - let depth = 254 // the parser permits up to maxDepth (256) nesting. - let src = String(repeating: "", count: depth) + "leaf" + String(repeating: "", count: depth) - let sheet = "" - + "" - + "" - let out = onLargeStack { (try? PureXML.XSLT.transform(stylesheet: sheet, source: src)) ?? "" } - #expect(out.contains("leaf")) - #expect(out.contains(String(repeating: "", count: depth))) + @Test("a runaway recursion fails gracefully at the configured limit") + func test_runawayFailsGracefully() { + // A low limit stands in for an unbounded recursion: it trips the guard + // cheaply and throws rather than building without bound. + #expect(throws: PureXML.XSLT.XSLTError.recursionLimitExceeded(100)) { + _ = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(5000), source: "", maxTemplateDepth: 100) } } -#endif + @Test("recursion within the limit transforms normally") + func test_withinLimitSucceeds() throws { + let out = try PureXML.XSLT.transform(stylesheet: recursiveStylesheet(100), source: "") + #expect(out.components(separatedBy: "").count - 1 == 99) + } + + @Test("an identity transform of the deepest permitted source runs on a small stack") + func test_identityOfMaxDepthSource() throws { + let depth = 254 // the parser permits up to maxDepth (256) nesting. + let src = String(repeating: "", count: depth) + "leaf" + String(repeating: "", count: depth) + let sheet = "" + + "" + + "" + let out = try PureXML.XSLT.transform(stylesheet: sheet, source: src) + #expect(out.contains("leaf")) + #expect(out.contains(String(repeating: "", count: depth))) + } +}