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:
parent
90ae16cba1
commit
8a7445c5bb
@ -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.
|
||||
|
||||
60
Sources/Schema/SchemaContentOrder.swift
Normal file
60
Sources/Schema/SchemaContentOrder.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
"""#)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 | |
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user