From ac85dd36de1a3541e65c24b33e339b9a01e06530 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 11 Jun 2026 06:24:57 +0200 Subject: [PATCH] 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. --- .github/workflows/swift-wasm.yml | 6 +++- CHANGELOG.md | 4 +++ PublicAPITests/PublicSurfaceTests.swift | 13 +++++++ README.md | 2 +- Sources/XPath/Budget.swift | 20 +++++++++++ Sources/XPath/EvaluationContext.swift | 10 ++++++ Sources/XPath/Evaluator.swift | 9 +++-- Sources/XPath/EvaluatorSteps.swift | 5 +-- Sources/XPath/Query.swift | 3 +- Sources/XPath/QueryError.swift | 4 +++ Sources/XSLT/XSLTCollation.swift | 48 +++++++++++++++++++++++++ Sources/XSLT/XSLTElements.swift | 9 ++--- Sources/XSLT/XSLTFunctions.swift | 6 ++-- Sources/XSLT/XSLTModel.swift | 6 +++- Sources/XSLT/XSLTParser.swift | 1 + Sources/XSLT/XSLTSorting.swift | 13 ++++++- Tests/Fixtures/xalan-baseline.txt | 2 -- scripts/check-wasm.sh | 16 +++++++++ 18 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 Sources/XPath/Budget.swift create mode 100644 Sources/XSLT/XSLTCollation.swift diff --git a/.github/workflows/swift-wasm.yml b/.github/workflows/swift-wasm.yml index 0b21003..ebf928b 100644 --- a/.github/workflows/swift-wasm.yml +++ b/.github/workflows/swift-wasm.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 413ed8a..e9ab08a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PublicAPITests/PublicSurfaceTests.swift b/PublicAPITests/PublicSurfaceTests.swift index 398df3e..24e436d 100644 --- a/PublicAPITests/PublicSurfaceTests.swift +++ b/PublicAPITests/PublicSurfaceTests.swift @@ -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("") + 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) + } } diff --git a/README.md b/README.md index 47a2b43..73b9d3e 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/XPath/Budget.swift b/Sources/XPath/Budget.swift new file mode 100644 index 0000000..23aa340 --- /dev/null +++ b/Sources/XPath/Budget.swift @@ -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) + } +} diff --git a/Sources/XPath/EvaluationContext.swift b/Sources/XPath/EvaluationContext.swift index 4d718b7..35eefd0 100644 --- a/Sources/XPath/EvaluationContext.swift +++ b/Sources/XPath/EvaluationContext.swift @@ -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, ) } } diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index 07913f5..e3c8748 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -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) } diff --git a/Sources/XPath/EvaluatorSteps.swift b/Sources/XPath/EvaluatorSteps.swift index 21846e0..ecfcd28 100644 --- a/Sources/XPath/EvaluatorSteps.swift +++ b/Sources/XPath/EvaluatorSteps.swift @@ -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 } diff --git a/Sources/XPath/Query.swift b/Sources/XPath/Query.swift index 0bbaac0..370a83d 100644 --- a/Sources/XPath/Query.swift +++ b/Sources/XPath/Query.swift @@ -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 diff --git a/Sources/XPath/QueryError.swift b/Sources/XPath/QueryError.swift index 7c2a8d4..515c1d0 100644 --- a/Sources/XPath/QueryError.swift +++ b/Sources/XPath/QueryError.swift @@ -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" diff --git a/Sources/XSLT/XSLTCollation.swift b/Sources/XSLT/XSLTCollation.swift new file mode 100644 index 0000000..0247c8a --- /dev/null +++ b/Sources/XSLT/XSLTCollation.swift @@ -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) + } + } +} diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index 6b82598..117b31e 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -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) diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 97c62b6..f63e0fd 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -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 diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index 30b930f..b7c2b9d 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -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 diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index 9909d4c..a9c723a 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -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), ) } } diff --git a/Sources/XSLT/XSLTSorting.swift b/Sources/XSLT/XSLTSorting.swift index be8a73f..b1f2709 100644 --- a/Sources/XSLT/XSLTSorting.swift +++ b/Sources/XSLT/XSLTSorting.swift @@ -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 diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index 535f507..83a1267 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -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 diff --git a/scripts/check-wasm.sh b/scripts/check-wasm.sh index e09b5de..d353465 100755 --- a/scripts/check-wasm.sh +++ b/scripts/check-wasm.sh @@ -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