feat(xpath,xslt,wasm): evaluation budget, tailored collation, WASI runtime proof

PureXML.XPath.Budget is the configurable counterpart of libxml2's
fixed 10M node-set cap (libxml2Compatible preset included), thrown as
a located QueryError instead of a silent refusal, default unbounded.
xsl:sort honors lang with tailored alphabet orders for Polish and
Russian derived from the conformance golds, case-folded. The WASM
gate executes all 985 tests under wasmtime on an actual WASI runtime,
wired into CI; the first run caught a real wasm32 trap (Int is 32
bits there, so format-number's digit extraction trapped at 2^31
despite the 2^53 guard; internals are now Int64 and the xsl:number
bound is platform-correct). Xalan baseline 127 -> 125. Closes #143,
closes #144.
This commit is contained in:
Mihaela Mihaljevic 2026-06-11 06:24:57 +02:00
parent 9e345ea0fe
commit ac85dd36de
18 changed files with 159 additions and 18 deletions

View File

@ -29,7 +29,11 @@ jobs:
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
swiftly install --use 6.3.2
swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c
- name: Build for WASM
- name: Install wasmtime
run: |
curl -sSf https://wasmtime.dev/install.sh | bash
echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH"
- name: Build and test on WASI
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
bash scripts/check-wasm.sh

View File

@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- The last parity items, to one hundred percent (#143, #144, #130): XPath gains a configurable protective evaluation budget (`PureXML.XPath.Budget`, with a `libxml2Compatible` preset matching its fixed 10M node-set cap), threaded through query evaluation and thrown as a located `QueryError.budgetExceeded` instead of libxml2's silent refusal, default unbounded; xsl:sort honors `lang` (an attribute value template) with tailored alphabet orders for Polish and Russian derived from the conformance golds (diacritics interleaved, Cyrillic Yo after Ye), case-folded, falling back to the default order for untailored languages; and the WASM gate executes the full test suite under wasmtime on an actual WASI runtime (build-only with a notice when wasmtime is absent), wired into CI. The first WASI run immediately caught a real wasm32 trap: format-number's digit extraction converted through Int, which is 32 bits on wasm32 and trapped at 2^31 despite the 2^53 guard; the internals are now explicitly Int64 and the xsl:number bound is platform-correct (`Double(Int.max)`). Xalan baseline 127 to 125.
### Added
- Public streaming surface (#142) and a public-API test target: `EventReader.next()`, both initializers, and the `Event` alias are public, so external consumers can pull events with bounded memory over arbitrarily large inputs (an internal typealias in a split file had been shadowing the public `Parsing.Event`, narrowing `next()` to internal; the benchmark harness found it). A new `PureXMLPublicAPITests` target compiles against the public surface without `@testable`, so any public facade over internal members fails CI; it pins parse/query/serialize, streaming pull, and the chunked pull source.
### Fixed

View File

@ -48,4 +48,17 @@ struct PublicSurfaceTests {
}
#expect(events == 5)
}
@Test("An evaluation budget throws instead of evaluating unbounded")
func test_evaluationBudget() throws {
let tree = try PureXML.parseTree("<r><a/><a/><a/><a/><a/></r>")
let query = try PureXML.XPath.Query("//a")
// Unbounded evaluates; a budget below the result size throws.
#expect(try query.value(at: tree).nodes?.count == 5)
#expect(throws: PureXML.XPath.QueryError.budgetExceeded(3)) {
_ = try query.value(at: tree, budget: .init(maxNodeSetLength: 3))
}
// The libxml2-compatible cap admits ordinary documents.
#expect(try query.value(at: tree, budget: .libxml2Compatible).nodes?.count == 5)
}
}

View File

@ -125,7 +125,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **984 tests in 168 suites** (`swift test`).
currently runs **985 tests in 168 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -0,0 +1,20 @@
public extension PureXML.XPath {
/// A protective evaluation budget: the maximum node-set length a query
/// may build. libxml2 enforces a fixed 10M-node cap and refuses larger
/// evaluations outright; PureXML evaluates unbounded by default and
/// lets the caller opt into a budget for untrusted input, failing with
/// a thrown ``QueryError/budgetExceeded(_:)`` instead of a silent
/// refusal. The counter is shared across one evaluation, including
/// nested predicate evaluations.
struct Budget: Sendable {
/// The largest node-set any step or union may produce.
public var maxNodeSetLength: Int
public init(maxNodeSetLength: Int) {
self.maxNodeSetLength = maxNodeSetLength
}
/// The libxml2-compatible cap.
public static let libxml2Compatible = Budget(maxNodeSetLength: 10_000_000)
}
}

View File

@ -20,6 +20,15 @@ extension PureXML.XPath {
/// namespace. Empty by default, in which case matching falls back to
/// the in-document prefix string.
var namespaces: [String: String] = [:]
/// The optional protective budget; nil evaluates unbounded.
var budget: Budget?
/// Throws when `count` exceeds the budget's node-set cap.
func checkBudget(_ count: Int) throws {
if let budget, count > budget.maxNodeSetLength {
throw QueryError.budgetExceeded(budget.maxNodeSetLength)
}
}
/// A copy positioned on `node` at one-based `position` within a node-set of
/// `size`, keeping the same variables, functions, and namespace bindings.
@ -31,6 +40,7 @@ extension PureXML.XPath {
variables: variables,
functions: functions,
namespaces: namespaces,
budget: budget,
)
}
}

View File

@ -61,8 +61,9 @@ extension PureXML.XPath {
variables: [String: Value],
functions: FunctionTable = FunctionTable(),
namespaces: [String: String] = [:],
budget: Budget? = nil,
) throws -> Value {
try value(_: expression, atNode: .tree(node), position: position, size: size, variables: variables, functions: functions, namespaces: namespaces)
try value(_: expression, atNode: .tree(node), position: position, size: size, variables: variables, functions: functions, namespaces: namespaces, budget: budget)
}
/// Like `value(_:at:...)` but starting from any XPath node, attributes
@ -75,6 +76,7 @@ extension PureXML.XPath {
variables: [String: Value],
functions: FunctionTable = FunctionTable(),
namespaces: [String: String] = [:],
budget: Budget? = nil,
) throws -> Value {
let context = EvaluationContext(
node: node,
@ -83,6 +85,7 @@ extension PureXML.XPath {
variables: variables,
functions: library.merging(functions),
namespaces: namespaces,
budget: budget,
)
return try eval(expression, context)
}
@ -144,7 +147,7 @@ extension PureXML.XPath {
private static func evalPath(absolute: Bool, steps: [Step], _ context: EvaluationContext) throws -> Value {
let start: Node = absolute ? root(of: context.node) : context.node
return .nodeSet(orderUnique(evaluateSteps(steps, from: [start], context)))
return try .nodeSet(orderUnique(evaluateSteps(steps, from: [start], context)))
}
private static func evalFilter(
@ -156,7 +159,7 @@ extension PureXML.XPath {
var nodes = try orderUnique(nodeSet(eval(primary, context)))
nodes = try applyPredicates(predicates, to: nodes, context)
if !steps.isEmpty {
nodes = orderUnique(evaluateSteps(steps, from: nodes, context))
nodes = try orderUnique(evaluateSteps(steps, from: nodes, context))
}
return .nodeSet(nodes)
}

View File

@ -11,7 +11,7 @@ extension PureXML.XPath.Evaluator {
/// Walks the steps from a starting node-set, threading each step's result into
/// the next. Predicates run with proximity position within each context node's
/// axis result.
static func evaluateSteps(_ steps: [Step], from start: [Node], _ context: EvaluationContext) -> [Node] {
static func evaluateSteps(_ steps: [Step], from start: [Node], _ context: EvaluationContext) throws -> [Node] {
var current = start
for step in steps {
var result: [Node] = []
@ -19,10 +19,11 @@ extension PureXML.XPath.Evaluator {
for contextNode in current {
let matched = PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode)
.filter { matches($0, step.test, on: step.axis, namespaces: context.namespaces) }
let filtered = (try? applyPredicates(step.predicates, to: matched, context)) ?? matched
let filtered = try applyPredicates(step.predicates, to: matched, context)
for node in filtered where seen.insert(node).inserted {
result.append(node)
}
try context.checkBudget(result.count)
}
current = result
}

View File

@ -64,8 +64,9 @@ public extension PureXML.XPath {
size: Int = 1,
variables: [String: Value] = [:],
namespaces: [String: String] = [:],
budget: Budget? = nil,
) throws -> Value {
try Evaluator.value(expression, at: node, position: position, size: size, variables: variables, namespaces: namespaces)
try Evaluator.value(expression, at: node, position: position, size: size, variables: variables, namespaces: namespaces, budget: budget)
}
/// Like ``value(at:position:size:variables:)`` but with an extra function

View File

@ -2,6 +2,9 @@ public extension PureXML.XPath {
/// An error compiling or evaluating an XPath query.
enum QueryError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
case empty
/// The evaluation budget was exceeded (``PureXML/XPath/Budget``):
/// a node-set grew past the configured maximum.
case budgetExceeded(Int)
case unexpectedToken(String)
case expectedNodeTest
case unterminatedPredicate
@ -18,6 +21,7 @@ public extension PureXML.XPath {
public var description: String {
switch self {
case .empty: "the XPath expression is empty"
case let .budgetExceeded(cap): "the evaluation budget was exceeded (node-set cap \(cap))"
case let .unexpectedToken(token): "unexpected token '\(token)'"
case .expectedNodeTest: "expected a node test"
case .unterminatedPredicate: "unterminated predicate"

View File

@ -0,0 +1,48 @@
extension PureXML.XSLT {
/// Language-tailored alphabet orders for xsl:sort, derived from the
/// conformance golds (the orders Xalan obtains from Java's collators).
/// A scalar found in the language's alphabet ranks by its tailored
/// position; anything else ranks after the alphabet by code point, and
/// lookups are case-folded so both cases share a rank. Languages
/// without a table fall back to the default (space-ignorable) order.
enum Collation {
/// Polish: diacritic letters interleave after their base letters.
private static let polish = "A\u{104}BC\u{106}DE\u{118}FGHIJKL\u{141}MN\u{143}O\u{D3}PRS\u{15A}TUWYZ\u{179}\u{17B}"
/// Russian: code-point order except \u{401} (Yo) after \u{415} (Ye).
private static let russian = "\u{410}\u{411}\u{412}\u{413}\u{414}\u{415}\u{401}\u{416}\u{417}\u{418}\u{419}\u{41A}\u{41B}\u{41C}\u{41D}\u{41E}\u{41F}"
+ "\u{420}\u{421}\u{422}\u{423}\u{424}\u{425}\u{426}\u{427}\u{428}\u{429}\u{42A}\u{42B}\u{42C}\u{42D}\u{42E}\u{42F}"
/// The rank table for a language tag, matched by primary subtag.
static func table(for lang: String) -> [Character: Int]? {
let primary = lang.split(separator: "-").first.map(String.init)?.lowercased() ?? lang.lowercased()
let alphabet: String
switch primary {
case "pl": alphabet = polish
case "ru": alphabet = russian
default: return nil
}
var ranks: [Character: Int] = [:]
for (rank, letter) in alphabet.enumerated() {
ranks[letter] = rank
for lowered in String(letter).lowercased() where ranks[lowered] == nil {
ranks[lowered] = rank
}
}
return ranks
}
/// Compares two strings by tailored rank, scalar by scalar; letters
/// outside the alphabet sort after it by code point, and equal-rank
/// prefixes fall back to length then exact content for stability.
static func compare(_ left: String, _ right: String, _ ranks: [Character: Int]) -> Int {
let beyond = ranks.count
for (first, second) in zip(left, right) where first != second {
let leftRank = ranks[first] ?? beyond + Int(first.unicodeScalars.first?.value ?? 0)
let rightRank = ranks[second] ?? beyond + Int(second.unicodeScalars.first?.value ?? 0)
if leftRank != rightRank { return leftRank < rightRank ? -1 : 1 }
}
if left.count != right.count { return left.count < right.count ? -1 : 1 }
return left == right ? 0 : (left < right ? -1 : 1)
}
}
}

View File

@ -171,10 +171,11 @@ extension PureXML.XSLT.Transformer {
}
if let valueExpression = spec.value {
let number = value(valueExpression, context)?.number ?? .nan
// Outside [1, 2^53) the number renders as a plain XPath number
// (below one there is nothing to format; above, the Int
// conversion would trap and a Double holds no fraction anyway).
guard number.isFinite, number.rounded() >= 1, number.rounded() < 9_007_199_254_740_992 else {
// Outside [1, Int.max] the number renders as a plain XPath
// number (below one there is nothing to format; above, the Int
// conversion would trap; the bound is platform-correct, so
// wasm32's 32-bit Int is honored too).
guard number.isFinite, number.rounded() >= 1, number.rounded() <= Double(Int.max) else {
return PureXML.XPath.Value.format(number)
}
return XSLTNumbering.format([Int(number.rounded())], spec.format, grouping)

View File

@ -147,12 +147,14 @@ extension PureXML.XSLT {
/// here may trap, whatever the value or the picture.
private static func digits(_ value: Double, scale: Double, places: Int) -> (String, String) {
let scaled = (value * scale).rounded()
// Int64 explicitly: on wasm32 Int is 32 bits and the conversion
// would trap at 2^31 (found by the WASI test run, #144).
guard scaled.isFinite, scaled < 9_007_199_254_740_992, scale < 9_007_199_254_740_992 else {
let integer = PureXML.XPath.Value.format(value.rounded(.towardZero))
return (integer, String(repeating: "0", count: places))
}
let total = Int(scaled)
let divisor = Int(scale)
let total = Int64(scaled)
let divisor = Int64(scale)
var fraction = String(total % divisor)
while fraction.count < places {
fraction = "0" + fraction

View File

@ -30,8 +30,12 @@ public extension PureXML.XSLT {
/// `case-order`: when set, the comparison is case-insensitive and this
/// breaks ties; when nil, the comparison is the default codepoint order.
public var caseOrder: CaseOrder?
/// `lang` as an attribute value template; a tailored alphabet order
/// applies when the evaluated language has one.
public var lang: ValueTemplate?
public init(select: String, descending: Bool, numeric: Bool, caseOrder: CaseOrder? = nil) {
public init(select: String, descending: Bool, numeric: Bool, caseOrder: CaseOrder? = nil, lang: ValueTemplate? = nil) {
self.lang = lang
self.select = select
self.descending = descending
self.numeric = numeric

View File

@ -325,6 +325,7 @@ extension PureXML.XSLT {
descending: XSLTNode.attribute(sort, "order") == "descending",
numeric: XSLTNode.attribute(sort, "data-type") == "number",
caseOrder: caseOrder(sort),
lang: XSLTNode.attribute(sort, "lang").map(valueTemplate),
)
}
}

View File

@ -23,9 +23,20 @@ extension PureXML.XSLT.Transformer {
keyContext.namespaces = context.namespaces
return SortEntry(offset: offset, node: xnode, keys: sorts.map { string($0.select, keyContext) })
}
// A lang attribute value template evaluates once per sort, in the
// caller's context; a known language selects its tailored alphabet.
let tailorings: [[Character: Int]?] = sorts.map { sort in
sort.lang.flatMap { PureXML.XSLT.Collation.table(for: avt($0, context)) }
}
return keyed.sorted { lhs, rhs in
for (index, sort) in sorts.enumerated() {
let order = Self.compareKeys(lhs.keys[index], rhs.keys[index], sort)
let order: Int
if !sort.numeric, sort.caseOrder == nil, let ranks = tailorings[index] {
let tailored = PureXML.XSLT.Collation.compare(lhs.keys[index], rhs.keys[index], ranks)
order = sort.descending ? -tailored : tailored
} else {
order = Self.compareKeys(lhs.keys[index], rhs.keys[index], sort)
}
if order != 0 { return order < 0 }
}
return lhs.offset < rhs.offset

View File

@ -109,8 +109,6 @@ select/select28
select/select67
select/select68
select/select75
sort/sort-alphabet-polish
sort/sort-alphabet-russian
sort/sort02
sort/sort10
sort/sort14

View File

@ -21,3 +21,19 @@ fi
swift_command build --swift-sdk "$SDK_ID"
swift_command build -c release --swift-sdk "$SDK_ID"
# Runtime proof (#144): when wasmtime is available, the full test suite
# executes on an actual WASI runtime; the first such run caught a real
# wasm32 trap (32-bit Int conversion in format-number). Build-only when
# wasmtime is absent, with a notice.
if command -v wasmtime >/dev/null 2>&1; then
swift_command build --build-tests --swift-sdk "$SDK_ID"
TEST_BINARY="$(ls .build/wasm32-*/debug/*PackageTests.xctest 2>/dev/null | head -1)"
if [ -n "$TEST_BINARY" ]; then
wasmtime run --dir . "$TEST_BINARY" --testing-library swift-testing
else
echo "wasm: test binary not found; skipping runtime execution" >&2
fi
else
echo "wasm: wasmtime not installed; build-only (install wasmtime for the runtime proof)" >&2
fi