feat(schema): enforce complexContent derivation content order

A restriction or extension inside complexContent has the content model
(annotation?, (group|all|choice|sequence)?, (attribute|attributeGroup)*,
anyAttribute?). The allowedChildren membership table admitted the right child
names but not their order or cardinality, so a second model group, a model
group after an attribute, an attribute after anyAttribute, or a second
anyAttribute was accepted.

A forward-only walk matches the children against an ordered slot model (one
model group, unbounded attributes, one anyAttribute). The check runs only in
the complexContent context, where a model group belongs to the model, so a
simpleContent derivation (facets, no model group) keeps its own rules and no
valid schema is rejected.

XSTS: invalid schemas accepted 864 to 785, valid schemas rejected held at 72,
instance buckets unchanged.
This commit is contained in:
Mihaela Mihaljevic 2026-06-14 09:56:04 +02:00
parent 90ae16cba1
commit 8a7445c5bb
6 changed files with 105 additions and 2 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A `complexContent` derivation's content is now checked for order and cardinality, not just child membership (#158). A `restriction` or `extension` inside `complexContent` has the content model `(annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?)`: an optional model group, then any number of `attribute`/`attributeGroup`, then an optional `anyAttribute`. The `allowedChildren` membership table admitted the right child names but not their sequence or count, so a second model group (`sequence` then `sequence`), a model group after an attribute, an attribute after `anyAttribute`, or a second `anyAttribute` was accepted. The children are matched against an ordered slot model (one model group, unbounded attributes, one `anyAttribute`) by a forward-only walk; the check runs only in the `complexContent` context, where a model group is part of the model, so a `simpleContent` derivation (facets, no model group) keeps its own rules and no valid schema is rejected. XSTS: invalid schemas accepted 864 to 785, no other bucket raised (valid schemas rejected held at 72).
- Unique Particle Attribution (`cos-nonambig`, the content-model determinism constraint) is now enforced at compile time (#156, #157), the keystone of the remaining schema-validity tail. A content model must be deterministic so each element in an instance is attributable to a single particle without lookahead; an ambiguous model (`<choice>` of two same-name elements, an optional or repeating element followed by a distinct same-name element, two same-name members of an `all`, an element competing with a wildcard that admits it) was accepted. The check runs a Glushkov position automaton over a literal, QName-resolved view of the raw schema tree built specifically for this rule, independent of the instance-validation content model: a `ref` is one position carrying its prefix-resolved QName (not the blanket target namespace), and substitution groups are not expanded, which removes the two false-overlap sources (cross-namespace refs, expanded substitution members) that a check over the compiled model produced. Determinism is per particle, the W3C rule's defining property: a particle competing with a repetition of itself is never a violation, so a fixed count `{n,n}` unrolls into copies in sequence (`b{2,2}, b` stays the deterministic `b, b, b`) while a true range or unbounded repeat uses a self-loop, and only two distinct source particles with overlapping labels conflict. The overlap test is QName-only (not substitution-group-aware), an accepted under-rejection that can never over-reject. XSTS: invalid schemas accepted 884 to 864, no other bucket raised (valid schemas rejected held at 72).
- A `keyref`'s `refer` must name a `key` or `unique` constraint (#147). A `refer` pointing at a non-existent (or non-key/unique) constraint was accepted; it is now resolved against the document's `key`/`unique` names, with a forward `refer` (textually before the key) handled by collecting all names first. The check is skipped when the document pulls in external definitions through `import`/`include`/`redefine`, which the default compile does not load. XSTS: invalid schemas accepted 891 to 884, no other bucket raised.
- Component-name uniqueness is now enforced (#147). Within a schema, global type names (`simpleType` and `complexType` share one symbol space), global element names, global attribute names, named model-group names, and named attribute-group names must each be unique, and identity-constraint names must be unique across the document; a duplicate was accepted, with the later definition silently overwriting the earlier. Only the document's own globals are examined (an `xs:redefine`'s same-named type is not a clash), and the symbol spaces are kept separate (a type and an element may share a name). XSTS: invalid schemas accepted 909 to 891, no other bucket raised.

View File

@ -0,0 +1,60 @@
extension PureXML.Schema.XSDParser {
/// Content-model order and cardinality for a `complexContent` derivation, the
/// ordering complement to the set-membership `allowedChildren` check (which
/// admits the right child names but not their sequence or count).
///
/// A `restriction`/`extension` inside `complexContent` has the model
/// `(annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?)`
/// (XSD 1.0 Structures): an optional model group, then any number of
/// `attribute`/`attributeGroup`, then an optional `anyAttribute`. A second model
/// group, a model group after an attribute, an attribute after `anyAttribute`, or
/// a second `anyAttribute` (the ctG/ctH families) was previously accepted.
/// Only the `complexContent` context is checked, where a model group is part of
/// the model; a `simpleContent` derivation (facets, no model group) has a
/// different model and is left to its own rules.
static func complexContentOrderErrors(_ complexContent: XSDTree) -> [String] {
var errors: [String] = []
for derivation in PureXML.Schema.XSDNode.elementChildren(complexContent) {
guard let local = PureXML.Schema.XSDNode.localName(derivation),
local == "restriction" || local == "extension"
else { continue }
let names = PureXML.Schema.XSDNode.elementChildren(derivation)
.filter { $0.name?.namespaceURI == xsdNamespace }
.compactMap(PureXML.Schema.XSDNode.localName)
.filter { $0 != "annotation" }
if let reason = slotOrderViolation(names, slots: complexDerivationSlots) {
errors.append("the content of a complexContent '\(local)' must be a model group, then attributes, then anyAttribute: \(reason)")
}
}
return errors
}
/// The ordered slots of a `complexContent` derivation: each lists its admitted
/// names and how many children may fill it (nil is unbounded).
private static let complexDerivationSlots: [(members: Set<String>, max: Int?)] = [
(["group", "all", "choice", "sequence"], 1),
(["attribute", "attributeGroup"], nil),
(["anyAttribute"], 1),
]
/// The first child that does not fit the ordered slot model, or nil. Each child
/// must fill the current slot or one after it (order is forward-only), and a slot
/// with a finite `max` may not be overfilled.
private static func slotOrderViolation(_ children: [String], slots: [(members: Set<String>, max: Int?)]) -> String? {
var slotIndex = 0
var counts = [Int](repeating: 0, count: slots.count)
for child in children {
var index = slotIndex
while index < slots.count, !slots[index].members.contains(child) {
index += 1
}
guard index < slots.count else { return "'\(child)' is out of order" }
counts[index] += 1
if let maximum = slots[index].max, counts[index] > maximum {
return "'\(child)' appears too many times"
}
slotIndex = index
}
return nil
}
}

View File

@ -148,6 +148,9 @@ extension PureXML.Schema.XSDParser {
if local == "complexType" {
errors += elementDeclsConsistentErrors(node)
}
if local == "complexContent" {
errors += complexContentOrderErrors(node)
}
}
for child in children {
collectStructure(child, into: &errors)

View File

@ -233,4 +233,43 @@ struct SchemaStructureTests {
<xs:element name="a"><xs:complexType><xs:attribute name="id" type="xs:string"/></xs:complexType></xs:element>
"""#))
}
@Test("a complexContent derivation's content must be ordered: model group, attributes, anyAttribute")
func test_complexContentDerivationOrder() {
func derive(_ kind: String, _ body: String) -> String {
#"""
<xs:complexType name="b"><xs:sequence><xs:element name="e" type="xs:string"/></xs:sequence></xs:complexType>
<xs:complexType name="t"><xs:complexContent><xs:\#(kind) base="b">\#(body)</xs:\#(kind)></xs:complexContent></xs:complexType>
"""#
}
// Two model groups (ctG/ctH "X then Y").
#expect(rejects(derive("restriction", #"<xs:sequence/><xs:sequence/>"#)))
#expect(rejects(derive("extension", #"<xs:choice/><xs:all/>"#)))
// A model group after an attribute.
#expect(rejects(derive("restriction", #"<xs:attribute name="x"/><xs:sequence/>"#)))
// An attribute after anyAttribute, and two anyAttributes.
#expect(rejects(derive("extension", #"<xs:anyAttribute/><xs:attribute name="x"/>"#)))
#expect(rejects(derive("restriction", #"<xs:sequence/><xs:anyAttribute/><xs:anyAttribute/>"#)))
}
@Test("a well-ordered complexContent derivation compiles")
func test_complexContentDerivationValid() throws {
try compile(#"""
<xs:complexType name="b"><xs:sequence><xs:element name="e" type="xs:string"/></xs:sequence></xs:complexType>
<xs:complexType name="t"><xs:complexContent>
<xs:extension base="b">
<xs:sequence><xs:element name="f" type="xs:string"/></xs:sequence>
<xs:attribute name="x" type="xs:string"/>
<xs:anyAttribute/>
</xs:extension>
</xs:complexContent></xs:complexType>
"""#)
// No model group is fine (an extension that only adds attributes).
try compile(#"""
<xs:complexType name="b"><xs:sequence><xs:element name="e" type="xs:string"/></xs:sequence></xs:complexType>
<xs:complexType name="t"><xs:complexContent>
<xs:extension base="b"><xs:attribute name="x" type="xs:string"/></xs:extension>
</xs:complexContent></xs:complexType>
"""#)
}
}

View File

@ -29,7 +29,7 @@ struct XSTSSuiteTests {
/// XSTS` (debug is far slower and the corpus is large). Per-case deviations
/// are written to /tmp/xsts-failures.txt for the burn-down.
private let knownSchemaValidRejected = 72
private let knownSchemaInvalidAccepted = 864
private let knownSchemaInvalidAccepted = 785
private let knownInstanceValidRejected = 214
private let knownInstanceInvalidAccepted = 165

View File

@ -10,7 +10,7 @@ irreducible tail. Do not deviate to new features while this is open.
| Bucket | Count | Reading |
|---|---|---|
| valid-schemas-rejected | 72 | we rarely reject a good schema |
| **invalid-schemas-accepted** | **2461 -> 864** | we rarely catch a bad one (facet, id, structural content-model, and UPA determinism validity landed) |
| **invalid-schemas-accepted** | **2461 -> 785** | we rarely catch a bad one (facet, id, structural content-model, UPA determinism, and complexContent-derivation order validity landed) |
| valid-instances-rejected | 233 | (instance side, separately tracked) |
| invalid-instances-accepted | 165 | |