PureXML/Sources/Schema/ContentMatcher.swift
Mihaela Mihaljevic 85d435d035 fix(schema,regex): bound NFA state explosion and class-subtraction crash; land XSTS baseline
Running the full W3C XSTS archive to completion exposed three crash and hang
classes, now fixed with regression tests, which unblocked the #129 first
measurement.

- Content-model and regex NFA builders now cap total state count (2^20),
  degrading further occurrence/quantifier repetition to star, so nested
  high-maxOccurs schemas no longer OOM (was 8 GB on the MS Particles set).
- Content-model validation advances one active state-set per child instead of
  re-walking the prefix each time, removing the O(children^2) spin.
- Regex class subtraction [abc-[b]] no longer traps on a reversed range, and a
  reversed range such as [z-a] is rejected cleanly.

XSTS first measurement baselined: 75 valid schemas rejected, 2467 invalid
schemas accepted, 604 valid instances rejected, 582 invalid instances accepted
across 14383 groups. The suite runs green in 8s, opt-in via XSTS_ROOT.

Refs #129, #122.
2026-06-13 18:04:25 +02:00

251 lines
11 KiB
Swift

extension PureXML.Schema {
/// A transition label in the content-model automaton: a specific expanded
/// element name, or a wildcard.
enum TermLabel: Sendable {
case name(PureXML.Model.QualifiedName)
case wildcard(Wildcard)
func matches(_ actual: PureXML.Model.QualifiedName) -> Bool {
switch self {
case let .wildcard(wildcard): wildcard.admits(actual)
case let .name(declared): declared.localName == actual.localName
&& declared.namespaceURI == actual.namespaceURI
}
}
}
/// One state of the content-model automaton.
struct ContentState: Sendable {
var epsilon: [Int] = []
var label: TermLabel?
var target: Int?
}
/// A Thompson NFA over element names compiled from a ``Particle``. `all` groups
/// are validated separately by ``ContentMatcher`` and are not represented here.
struct ContentNFA: Sendable {
var states: [ContentState]
var start: Int
var accept: Int
func matchesWhole(_ names: [PureXML.Model.QualifiedName]) -> Bool {
var current = startStates()
for name in names {
guard let next = step(current, over: name) else { return false }
current = next
}
return isComplete(current)
}
/// After consuming `names`, the labels the automaton can accept next and
/// whether the content may legally end here. The follow-set: the exact set
/// of element names allowed at this point, for completions and for telling
/// whether something is still required. Returns an empty, not-complete
/// result when the prefix is already invalid.
func follow(after names: [PureXML.Model.QualifiedName]) -> (allowed: [TermLabel], complete: Bool) {
var current = startStates()
for name in names {
guard let next = step(current, over: name) else { return ([], false) }
current = next
}
return (admissible(from: current), isComplete(current))
}
// MARK: Incremental matching
//
// The active set is the epsilon-closure of the states reachable after some
// prefix of children. Callers that scan a child sequence advance it one
// child at a time (`startStates` then `step` per child) rather than calling
// `follow(after:)` on a growing prefix, which re-walks the whole prefix each
// time and is quadratic in the child count over a content model (#129).
/// The active set before any child: the closure of the start state.
func startStates() -> Set<Int> {
closure([start])
}
/// The labels admissible from `current`, for diagnostics and completions.
func admissible(from current: Set<Int>) -> [TermLabel] {
var labels: [TermLabel] = []
for state in current where states[state].target != nil {
if let label = states[state].label { labels.append(label) }
}
return labels
}
/// Advances `current` by consuming one element `name`, or nil when `name`
/// is not admissible there.
func step(_ current: Set<Int>, over name: PureXML.Model.QualifiedName) -> Set<Int>? {
var next: Set<Int> = []
for state in current {
if let label = states[state].label, let target = states[state].target, label.matches(name) {
next.insert(target)
}
}
if next.isEmpty { return nil }
return closure(next)
}
/// Whether the content may legally end at `current`.
func isComplete(_ current: Set<Int>) -> Bool {
current.contains(accept)
}
private func closure(_ seed: Set<Int>) -> Set<Int> {
var seen = seed
var stack = Array(seed)
while let state = stack.popLast() {
for next in states[state].epsilon where seen.insert(next).inserted {
stack.append(next)
}
}
return seen
}
}
/// Builds a ``ContentNFA`` from a particle by Thompson construction.
struct ContentNFABuilder {
private var states: [ContentState] = []
static func build(_ particle: PureXML.Schema.Particle) -> ContentNFA {
var builder = ContentNFABuilder()
let fragment = builder.particle(particle)
return ContentNFA(states: builder.states, start: fragment.start, accept: fragment.accept)
}
private mutating func addState() -> Int {
states.append(ContentState())
return states.count - 1
}
/// Occurrence counts unroll into automaton states, so they are
/// capped: beyond the cap a repetition is treated as unbounded, the
/// libxml2 posture (its cap is 16384). Without this, a schema with
/// maxOccurs in the trillions allocates until the process dies
/// (found by XSTS groupF009v, #129).
private static let occursUnrollCap = 16384
/// The per-particle cap bounds one repetition, but nested particles
/// multiply: a sequence of 16384 holding a choice of 16384 is 2.7e8
/// states even though each particle is individually capped. So the
/// total state count is also capped; once an NFA reaches the ceiling
/// every further repetition degrades to star, the same posture the
/// per-particle cap already takes. Legitimate content models are tens
/// of states, so only the pathological XSTS particle schemas (the
/// msMeta Particles set, which OOM-killed the suite at 8 GB, #129)
/// reach it. The ceiling sits well above a single maxed particle
/// (~33k states) so capped-but-not-nested schemas are unaffected.
private static let totalStateCap = 1 << 20
private var stateBudgetExhausted: Bool {
states.count >= Self.totalStateCap
}
private mutating func particle(_ particle: PureXML.Schema.Particle) -> (start: Int, accept: Int) {
let start = addState()
var current = start
let boundedMin = Swift.min(particle.minOccurs, Self.occursUnrollCap)
var unrolledMin = 0
for _ in 0 ..< boundedMin {
if stateBudgetExhausted { break }
let part = term(particle.term)
states[current].epsilon.append(part.start)
current = part.accept
unrolledMin += 1
}
let accept = addState()
if stateBudgetExhausted || unrolledMin < boundedMin || particle.minOccurs > Self.occursUnrollCap {
// The tail of an absurd minOccurs, or a run that hit the total
// state ceiling mid-unroll, is approximated as star.
appendStar(particle.term, from: current, to: accept)
} else if let maximum = particle.maxOccurs, maximum - boundedMin <= Self.occursUnrollCap {
appendOptional(particle.term, count: maximum - boundedMin, from: current, to: accept)
} else {
appendStar(particle.term, from: current, to: accept)
}
return (start, accept)
}
private mutating func appendOptional(_ term: PureXML.Schema.Term, count: Int, from: Int, to accept: Int) {
var current = from
for _ in 0 ..< Swift.max(0, count) {
if stateBudgetExhausted {
// Ran out of state budget: let the remainder repeat freely
// (star) rather than keep allocating bounded optionals.
appendStar(term, from: current, to: accept)
return
}
let part = self.term(term)
states[current].epsilon.append(part.start)
states[current].epsilon.append(accept)
current = part.accept
}
states[current].epsilon.append(accept)
}
private mutating func appendStar(_ term: PureXML.Schema.Term, from current: Int, to accept: Int) {
let part = self.term(term)
states[current].epsilon.append(part.start)
states[current].epsilon.append(accept)
states[part.accept].epsilon.append(part.start)
states[part.accept].epsilon.append(accept)
}
private mutating func term(_ term: PureXML.Schema.Term) -> (start: Int, accept: Int) {
switch term {
case let .element(name, _):
labeled(.name(name))
case let .wildcard(wildcard):
labeled(.wildcard(wildcard))
case let .group(group):
self.group(group)
}
}
private mutating func labeled(_ label: TermLabel) -> (start: Int, accept: Int) {
let start = addState()
let accept = addState()
states[start].label = label
states[start].target = accept
return (start, accept)
}
private mutating func group(_ group: PureXML.Schema.Group) -> (start: Int, accept: Int) {
// `all` is validated by counting, not in the NFA; treat it as a
// sequence here so a nested occurrence still parses.
group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
}
private mutating func sequence(_ particles: [PureXML.Schema.Particle]) -> (start: Int, accept: Int) {
guard !particles.isEmpty else { return labeledEmpty() }
var first: Int?
var last: Int?
for member in particles {
let part = particle(member)
if let previous = last { states[previous].epsilon.append(part.start) } else { first = part.start }
last = part.accept
}
return (first ?? addState(), last ?? addState())
}
private mutating func choice(_ particles: [PureXML.Schema.Particle]) -> (start: Int, accept: Int) {
let start = addState()
let accept = addState()
for member in particles {
let part = particle(member)
states[start].epsilon.append(part.start)
states[part.accept].epsilon.append(accept)
}
return (start, accept)
}
private mutating func labeledEmpty() -> (start: Int, accept: Int) {
let start = addState()
let accept = addState()
states[start].epsilon.append(accept)
return (start, accept)
}
}
}