PureXML/Tests/ContentModelDeterminismGroupReferenceTests.swift
Mihaela Mihaljevic 6957e96b3a
fix(schema): reject overlapping references to one group as a UPA violation, closing a hang (#366)
Two references to the same <xs:group> are two expanded occurrences for Unique Particle
Attribution, so when they overlap, (g?, g?), (g | g), (g*, g), the model is ambiguous,
exactly as inline (x?, x?) / (x | x) is, and as libxml2/Xerces reject them. The
compositional determinism check summarises each group once and reused the group's node
identity across references, so the distinct-identity overlap test could not see the
conflict: PureXML accepted these (an invalid-schemas-accepted false negative). It also let
the per-instance content matcher run on a non-UPA model, where a multiply-referenced
nullable group is exponential, so a depth-18 optional doubling chain was accepted and then
hung instance validation: a keystroke-time hang on a schema PureXML itself accepted.

The fix dedupes each decision set (first set, sequence gap, all cross-follow) by particle
identity and treats a same-identity collision as the conflict; the repetition self-loop
(a*, (a*)*) keeps a non-flagging dedupe so one particle looping is not misread as two
occurrences. This also bounds the check, the deduped first/follow sets stay O(labels) not
2^K, so the depth-40 chain is now rejected at compile in 11 ms and no instance is matched
against it. A redefine self-extension lists a type's own content twice through
effectiveModelNodes; that artifact is deduped before the walk so it is not a false
conflict (addB094, schZ007).

The repeat-edge fold is kept in a separate method, not inline in summarize: summarize
recurses on nesting depth, so a local it holds multiplies the per-level stack frame and a
deep group chain overflows WASI's small stack (the recursive check's deeper WASI depth-
safety is tracked separately).

Verified over the full W3C XSTS corpus in release: valid-schemas-rejected and
valid-instances-rejected stay 0, invalid-*-accepted unchanged at 8 and 14; an adversarial
over-rejection critic found no valid schema newly rejected; the full suite passes on
macOS, Linux, and WASI (run locally under wasmtime).
2026-06-28 17:56:46 +02:00

89 lines
5.5 KiB
Swift

import Testing
@testable import PureXML
/// Two references to the same `<xs:group>` are two expanded occurrences for Unique
/// Particle Attribution, so when they overlap (`(g?, g?)`, `(g | g)`) the model is
/// ambiguous, exactly as the inline `(x?, x?)` / `(x | x)` is. The compositional
/// determinism check summarizes a group once and reused its node identity across
/// references, so the distinct-identity overlap test could not see the conflict and
/// PureXML accepted these where libxml2/Xerces reject them. That also let the per-
/// instance content matcher run on a non-UPA model, where a multiply-referenced
/// nullable group is exponential, a keystroke-time hang on an accepted schema. The
/// fix (dedup decision sets by identity, treat a same-identity collision as the
/// conflict) closes the gap and bounds the check.
@Suite("UPA on multiply-referenced group overlap")
struct DeterminismGroupReferenceTests {
private let header = "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">"
private let groupG = "<xs:group name=\"g\"><xs:sequence><xs:element name=\"x\"/></xs:sequence></xs:group>"
private func accepts(_ body: String) -> Bool {
(try? PureXML.Schema.Document(header + body + "</xs:schema>")) != nil
}
private func model(_ content: String, withGroup: Bool = true) -> String {
(withGroup ? groupG : "") + "<xs:element name=\"d\"><xs:complexType>\(content)</xs:complexType></xs:element>"
}
@Test("two overlapping references to one group are rejected as ambiguous")
func test_overlappingGroupReferencesRejected() {
// (g?, g?): a leading element of g matches the first or the second reference.
#expect(!accepts(model("<xs:sequence><xs:group ref=\"g\" minOccurs=\"0\"/><xs:group ref=\"g\" minOccurs=\"0\"/></xs:sequence>")))
// (g | g): a leading element of g matches either branch.
#expect(!accepts(model("<xs:choice><xs:group ref=\"g\"/><xs:group ref=\"g\"/></xs:choice>")))
// (g*, g): after a run of g, the next could be the repeat or the trailing g.
#expect(!accepts(model("<xs:sequence><xs:group ref=\"g\" maxOccurs=\"unbounded\"/><xs:group ref=\"g\"/></xs:sequence>")))
}
@Test("references to one group that do NOT overlap stay valid (no over-rejection)")
func test_nonOverlappingGroupReferencesAccepted() {
// (g, g): a required g then a required g is a deterministic two-element sequence.
#expect(accepts(model("<xs:sequence><xs:group ref=\"g\"/><xs:group ref=\"g\"/></xs:sequence>")))
// (g, g*): g then zero-or-more g is g repeated, unambiguous (the a+ shape).
#expect(accepts(model("<xs:sequence><xs:group ref=\"g\"/><xs:group ref=\"g\" maxOccurs=\"unbounded\"/></xs:sequence>")))
// A single group reference, repeated, is one particle looping: valid.
#expect(accepts(model("<xs:sequence><xs:group ref=\"g\" minOccurs=\"0\" maxOccurs=\"unbounded\"/></xs:sequence>")))
}
@Test("nested repetition of a single element is not a same-particle conflict")
func test_nestedSingleElementRepetitionAccepted() {
// ((a{0,M}){0,M}){0,M}: one element 'a' nested in repetitions is a*, unambiguous.
// The repeat-edge self-loop must not be read as two occurrences (#192 shape).
let content = "<xs:sequence maxOccurs=\"100\"><xs:sequence maxOccurs=\"100\">"
+ "<xs:choice maxOccurs=\"100\"><xs:element name=\"a\" maxOccurs=\"100\"/></xs:choice>"
+ "</xs:sequence></xs:sequence>"
#expect(accepts(model(content, withGroup: false)))
}
/// `g0=(g1?,g1?) … g{K-1}=(gK?,gK?), gK=(x?)`: every level optional and referenced
/// twice. Inlined this is the `2^K` overlap that hung the matcher; the determinism
/// check now rejects it in work linear in the depth, so no instance is ever matched
/// against it (the interactive-safety payoff).
private func optionalDoublingSchema(depth: Int) -> String {
var parts = [header]
for level in 0 ..< depth {
parts.append("<xs:group name=\"g\(level)\"><xs:sequence>"
+ "<xs:group ref=\"g\(level + 1)\" minOccurs=\"0\"/><xs:group ref=\"g\(level + 1)\" minOccurs=\"0\"/>"
+ "</xs:sequence></xs:group>")
}
parts.append("<xs:group name=\"g\(depth)\"><xs:sequence><xs:element name=\"x\" minOccurs=\"0\"/></xs:sequence></xs:group>")
parts.append("<xs:complexType name=\"T\"><xs:sequence><xs:group ref=\"g0\"/></xs:sequence></xs:complexType>")
parts.append("<xs:element name=\"doc\" type=\"T\"/></xs:schema>")
return parts.joined()
}
// The determinism check is the linear-in-depth rejection's home, but it recurses on
// nesting depth, so a depth-40 chain overflows WASI's small single-threaded stack
// (the recursive check/builder depth-safety on WASI is tracked separately). The bound
// is platform-independent, so this runs on the large-stack hosts (macOS, Linux); the
// shallow correctness tests above cover the fix on WASI too.
#if !os(WASI)
@Test("a deep multiply-referenced nullable group chain is rejected in bounded work", .timeLimit(.minutes(1)))
func test_deepOptionalDoublingRejectedBounded() {
// 2^40 inlined occurrences: it must be rejected by the linear-in-depth check,
// not explored. Reaching this test's end at all is the bound (the time limit
// turns a regression to the exponential back into a failure, not a hang).
#expect((try? PureXML.Schema.Document(optionalDoublingSchema(depth: 40))) == nil)
}
#endif
}