diff --git a/CHANGELOG.md b/CHANGELOG.md index 6629f7d..5a58bb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The content-model matcher no longer blows up on a nested model group with a huge but + finite `maxOccurs` (#192, partial). The counted-automaton active set is bounded by the + product of the per-counter saturation caps; a finite `maximum` the input cannot reach + (`maximum > inputLength + 1`) is now saturated at its minimum, exactly as an unbounded + counter is, because the value can never reach that maximum under any continuation (the + cap is at most `inputLength + 1`), so `belowMaximum` always holds and every value at or + above the minimum is matching-equivalent. A document that did not terminate within a + minute against such a model (the Z035 worst case) now validates in linear time, with no + change to any accept/reject decision (full XSTS suite still 0/35/0/26; an adversarial + no-caps reference-matcher differential confirms no verdict changed, including nullable + bodies and the occurrence boundary). Guarded by `ContentMatcherWorstCaseTests`. A model + with several *moderate* (input-reachable) `maxOccurs` nested deeply still enumerates a + large active set and is tracked under #192. + - A reference to a model group whose content is an `all` group must have `maxOccurs` of 1 and `minOccurs` of 0 or 1 (XSD 1.0 cos-all-limited, #158). A `` that resolves to an `` group with `maxOccurs="2"` is now rejected (corpus diff --git a/Sources/Schema/ContentMatcher.swift b/Sources/Schema/ContentMatcher.swift index 373eb9b..6f27327 100644 --- a/Sources/Schema/ContentMatcher.swift +++ b/Sources/Schema/ContentMatcher.swift @@ -245,19 +245,32 @@ extension PureXML.Schema { /// The per-counter saturation point for the active-set representation. /// - /// An occurrence counter only needs distinct values up to what its own - /// bound and the available input make observable. A bounded counter - /// saturates at `min(maximum, inputLength + 1)`. An unbounded counter - /// saturates at its `minimum`: once the minimum is reached every higher - /// count is matching-equivalent (`canExit` stays satisfied and - /// `belowMaximum` is vacuous), so collapsing them is exact. Clamping each - /// counter independently bounds the active-configuration set by the content - /// model instead of by `inputLength` raised to the counter count, so a long - /// run of one repeated element no longer makes the closure blow up. + /// An occurrence counter only needs distinct values up to what its own bound + /// and the available input make observable. An unbounded counter saturates at + /// its `minimum`: once the minimum is reached every higher count is + /// matching-equivalent (`canExit` stays satisfied and `belowMaximum` is + /// vacuous), so collapsing them is exact. + /// + /// A finite `maximum` the input cannot reach is matching-equivalent to + /// unbounded and saturates the same way. The active set increments a counter + /// at most to its cap, and the cap is at most `inputLength + 1`; so when + /// `maximum > inputLength + 1` the value can never reach `maximum` under any + /// continuation, `belowMaximum` is always satisfied, and every value `>= min` + /// is indistinguishable. Saturating such a counter at its minimum (rather than + /// at `min(maximum, inputLength + 1)`) is therefore exact, and it keeps a huge + /// but finite `maxOccurs` on a nested counter from multiplying the + /// active-configuration set by an `inputLength` factor per nesting level (the + /// Z035 worst case). Only a `maximum` the input can actually reach + /// (`<= inputLength + 1`) still needs its values tracked up to that maximum. private func counterCaps(for inputLength: Int) -> [Int] { let reachable = inputLength == Int.max ? Int.max : inputLength + 1 return counters.map { scope in - scope.range.maximum.clamped(to: reachable) ?? scope.range.minimum.clamped(to: reachable) + guard let maximumCap = scope.range.maximum.clamped(to: reachable), + !scope.range.maximum.isGreaterThan(reachable) + else { + return scope.range.minimum.clamped(to: reachable) + } + return maximumCap } } } diff --git a/Tests/ContentMatcherWorstCaseTests.swift b/Tests/ContentMatcherWorstCaseTests.swift new file mode 100644 index 0000000..affb0e0 --- /dev/null +++ b/Tests/ContentMatcherWorstCaseTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import PureXML + +/// #192 worst-case guard. A content model with nested huge-`maxOccurs` counters (the +/// Z035-class adversarial shape) must validate a large document in bounded work, not +/// blow up the active-configuration set. Before effectively-unbounded finite counters +/// were saturated at their minimum, an 800-child document against this model did not +/// terminate within a minute (the active set grew like `inputLength` raised to the +/// nesting depth); it is now linear. The time limit turns a regression back to the +/// super-linear blowup into a test failure rather than a silent interactive hang. +/// +/// This covers the huge-`maxOccurs` case (a maximum the input cannot reach). A model +/// with several MODERATE `maxOccurs` (reachable by the document) nested deeply is not +/// yet bounded to a small constant and remains tracked in #192. +@Suite("XSD content-matcher worst case (#192)") +struct ContentMatcherWorstCaseTests { + private let hugeMaxOccursModel = """ + + + + + + + + """ + + #if os(WASI) + @Test("a huge-maxOccurs nested content model validates a large document in bounded work") + #else + @Test("a huge-maxOccurs nested content model validates a large document in bounded work", .timeLimit(.minutes(1))) + #endif + func test_hugeMaxOccursBounded() throws { + let document = try PureXML.Schema.Document(hugeMaxOccursModel) + let valid = "" + String(repeating: "x", count: 10000) + "" + #expect(try document.validate(valid).isEmpty) + // A child the model does not admit is still correctly rejected (the saturation + // does not widen what is accepted), and that decision is also bounded. + let invalid = "" + String(repeating: "x", count: 5000) + "" + #expect(try !document.validate(invalid).isEmpty) + } +}