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:
parent
9e345ea0fe
commit
ac85dd36de
6
.github/workflows/swift-wasm.yml
vendored
6
.github/workflows/swift-wasm.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
20
Sources/XPath/Budget.swift
Normal file
20
Sources/XPath/Budget.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
48
Sources/XSLT/XSLTCollation.swift
Normal file
48
Sources/XSLT/XSLTCollation.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user