fix(schema): support inline element types in substitution group exclusions

This commit is contained in:
Mihaela Mihaljevic 2026-06-15 21:40:01 +02:00
parent ec4b2aa80c
commit d0e0b96de9
5 changed files with 177 additions and 42 deletions

View File

@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Enforce substitution group exclusions based on element final and schema finalDefault attributes (XSD 1.0 Structures §3.3.6 / Schema Component Constraint: Substitution Group OK (Transitive)). The entire derivation path from the member type to the head type is checked; if any step contains a derivation method prohibited by the head element's final or the schema's finalDefault, the substitution is rejected. XSTS: invalid schemas accepted 323 to 318, no other bucket raised.
- Enforce substitution group exclusions based on element final and schema finalDefault attributes (XSD 1.0 Structures §3.3.6 / Schema Component Constraint: Substitution Group OK (Transitive)), including support for element declarations with inline anonymous types. The entire derivation path from the member type to the head type is checked; if any step contains a derivation method prohibited by the head element's final or the schema's finalDefault, the substitution is rejected. XSTS: invalid schemas accepted 323 to 316, no other bucket raised.
- Enforce targetNamespace scope matching on local schema references (XSD 1.0 Structures §3.14). If a schema has a targetNamespace declared, any local type, element, attribute, group, or attributeGroup reference without a namespace prefix (resolving to no namespace) is rejected as undeclared unless a default namespace matching the targetNamespace is declared on the element, since all global components defined in the document belong to the targetNamespace. XSTS: invalid schemas accepted 325 to 323, no other bucket raised.
- Enforce variety constraints on list itemType and union memberTypes (XSD 1.0 Datatypes §3.3.1 / §3.4.1). A list's itemType must be atomic or union of atomic types, and cannot be a list or a union containing a list, and cannot resolve to 'anySimpleType'. Resolves namespace prefixes dynamically in scope to handle local prefix declarations without false positives. XSTS: invalid schemas accepted 327 to 325, no other bucket raised.
- Implement namespace-aware schema reference resolution checks, validating type references, element/attribute/group references, and substitutionGroup headers using XML namespace prefix bindings (XSD 1.0 Structures §3.14). If a prefix is bound to a namespace that is neither the targetNamespace nor the XMLSchema namespace (when no external schemas are loaded), the reference is flagged as undeclared. Bypasses built-in validation checks on references in the XMLSchema namespace when the targetNamespace is the XMLSchema namespace itself. Validate simpleContent base restrictions namespace-aware. XSTS: invalid schemas accepted 333 to 327, no other bucket raised.

View File

@ -32,77 +32,158 @@ extension PureXML.Schema.XSDParser {
globalElementType[name] = SubTypeNode.stripPrefix(type)
}
}
var errors: [String] = []
for element in globals {
guard let member = SubTypeNode.attribute(element, "name"),
let headReference = SubTypeNode.attribute(element, "substitutionGroup"),
// An element with no `type` attribute has an inline or absent type:
// an untyped member inherits the head's type and derives trivially,
// so it is correctly skipped.
let memberType = SubTypeNode.attribute(element, "type").map(SubTypeNode.stripPrefix)
else { continue }
let head = SubTypeNode.stripPrefix(headReference)
// No entry means an untyped head (the ur-type, which admits any member) or
// an unresolved head; either way there is nothing to reject here.
guard let headType = globalElementType[head] else { continue }
// List/union derivation is not modelled; stand down to avoid rejecting a
// valid member (e.g. an integer member of a union-typed head).
guard !isListOrUnion(memberType, types), !isListOrUnion(headType, types) else { continue }
if !PureXML.Schema.ParticleRestriction.typeDerivesOrEqual(memberType, headType, tables.typeDerivation, types) {
errors.append("element '\(member)' may not be in the substitution group of '\(head)': its type '\(memberType)' is not derived from '\(headType)'")
} else {
errors += checkExclusions(
member: member,
memberType: memberType,
head: head,
headType: headType,
tables: tables,
)
}
return globals.flatMap { element in
checkSingleElement(element, globalElementType, tables, types)
}
return errors
}
private static func checkSingleElement(
_ element: XSDTree,
_ globalElementType: [String: String],
_ tables: DerivationTables,
_ types: [String: PureXML.Schema.ElementType],
) -> [String] {
guard let member = SubTypeNode.attribute(element, "name"),
let headReference = SubTypeNode.attribute(element, "substitutionGroup")
else { return [] }
let head = SubTypeNode.stripPrefix(headReference)
guard let headType = globalElementType[head] else { return [] }
if isInlineListOrUnion(element) { return [] }
let inlineDeriv = inlineTypeDerivation(element)
let isDerived: Bool
let memberType: String
if let inlineDeriv {
memberType = "anonymous"
isDerived = inlineDeriv.base == headType || PureXML.Schema.ParticleRestriction.typeDerivesOrEqual(inlineDeriv.base, headType, tables.typeDerivation, types)
} else if let typeAttr = SubTypeNode.attribute(element, "type").map(SubTypeNode.stripPrefix) {
memberType = typeAttr
isDerived = PureXML.Schema.ParticleRestriction.typeDerivesOrEqual(memberType, headType, tables.typeDerivation, types)
} else {
memberType = headType
isDerived = true
}
guard !isListOrUnion(memberType, types), !isListOrUnion(headType, types) else { return [] }
if !isDerived {
return ["element '\(member)' may not be in the substitution group of '\(head)': its type is not derived from '\(headType)'"]
}
return checkExclusions(
member: member,
memberType: memberType,
inlineDeriv: inlineDeriv,
headInfo: (head, headType),
tables: tables,
)
}
private static func checkExclusions(
member: String,
memberType: String,
head: String,
headType: String,
inlineDeriv: PureXML.Schema.TypeDerivation?,
headInfo: (head: String, headType: String),
tables: DerivationTables,
) -> [String] {
guard let exclusions = tables.elementFinal[head] else { return [] }
guard let exclusions = tables.elementFinal[headInfo.head] else { return [] }
var errors: [String] = []
var currentType = memberType
var pathMethods: Set<PureXML.Schema.DerivationMethod> = []
while currentType != headType {
var currentType: String
if let inlineDeriv {
pathMethods.insert(inlineDeriv.method)
currentType = inlineDeriv.base
} else {
currentType = memberType
}
var visited: Set<String> = [currentType]
while currentType != headInfo.headType {
guard let derivation = tables.typeDerivation[currentType] else {
break
}
pathMethods.insert(derivation.method)
guard visited.insert(derivation.base).inserted else {
break
}
currentType = derivation.base
}
if currentType != headType {
if currentType != headInfo.headType {
let member = PureXML.Schema.BuiltinType(rawValue: currentType)
let head = PureXML.Schema.BuiltinType(rawValue: headType)
let head = PureXML.Schema.BuiltinType(rawValue: headInfo.headType)
if let member, let head, member.derives(from: head) {
pathMethods.insert(.restriction)
}
}
if exclusions.contains(.extension), pathMethods.contains(.extension) {
errors.append(
"element '\(member)' may not be in the substitution group of '\(head)': "
+ "its type '\(memberType)' is derived by extension from '\(headType)' which is excluded by the head element",
"element '\(member)' may not be in the substitution group of '\(headInfo.head)': "
+ "its type '\(memberType)' is derived by extension from '\(headInfo.headType)' which is excluded by the head element",
)
}
if exclusions.contains(.restriction), pathMethods.contains(.restriction) {
errors.append(
"element '\(member)' may not be in the substitution group of '\(head)': "
+ "its type '\(memberType)' is derived by restriction from '\(headType)' which is excluded by the head element",
"element '\(member)' may not be in the substitution group of '\(headInfo.head)': "
+ "its type '\(memberType)' is derived by restriction from '\(headInfo.headType)' which is excluded by the head element",
)
}
return errors
}
private static func inlineTypeDerivation(_ element: XSDTree) -> PureXML.Schema.TypeDerivation? {
if let complex = SubTypeNode.elementChildren(element).first(where: {
$0.name?.namespaceURI == xsdNamespace && SubTypeNode.localName($0) == "complexType"
}) {
if let info = derivationInfo(complex) {
return info
}
return PureXML.Schema.TypeDerivation(base: "anyType", method: .restriction)
}
if let simple = SubTypeNode.elementChildren(element).first(where: {
$0.name?.namespaceURI == xsdNamespace && SubTypeNode.localName($0) == "simpleType"
}) {
return simpleTypeNodeDerivation(simple)
}
return nil
}
private static func simpleTypeNodeDerivation(_ simple: XSDTree) -> PureXML.Schema.TypeDerivation? {
if let restriction = SubTypeNode.elementChildren(simple).first(where: {
$0.name?.namespaceURI == xsdNamespace && SubTypeNode.localName($0) == "restriction"
}) {
if let base = SubTypeNode.attribute(restriction, "base") {
return PureXML.Schema.TypeDerivation(base: SubTypeNode.stripPrefix(base), method: .restriction)
}
if let inlineBase = SubTypeNode.elementChildren(restriction).first(where: {
$0.name?.namespaceURI == xsdNamespace && SubTypeNode.localName($0) == "simpleType"
}) {
return simpleTypeNodeDerivation(inlineBase)
}
}
return PureXML.Schema.TypeDerivation(base: "anySimpleType", method: .restriction)
}
private static func isInlineListOrUnion(_ element: XSDTree) -> Bool {
guard let simple = SubTypeNode.elementChildren(element).first(where: {
$0.name?.namespaceURI == xsdNamespace && SubTypeNode.localName($0) == "simpleType"
}) else { return false }
return isSimpleNodeListOrUnion(simple)
}
private static func isSimpleNodeListOrUnion(_ simple: XSDTree) -> Bool {
if SubTypeNode.elementChildren(simple).contains(where: {
let local = SubTypeNode.localName($0)
return $0.name?.namespaceURI == xsdNamespace && (local == "list" || local == "union")
}) {
return true
}
if let restriction = SubTypeNode.elementChildren(simple).first(where: {
$0.name?.namespaceURI == xsdNamespace && SubTypeNode.localName($0) == "restriction"
}) {
if let inlineBase = SubTypeNode.elementChildren(restriction).first(where: {
$0.name?.namespaceURI == xsdNamespace && SubTypeNode.localName($0) == "simpleType"
}) {
return isSimpleNodeListOrUnion(inlineBase)
}
}
return false
}
/// Whether the named type is a list- or union-variety simple type, whose
/// derivation rules `typeDerivesOrEqual` does not model. A built-in or unknown
/// name is treated as atomic.

View File

@ -158,4 +158,58 @@ struct SchemaSubstitutionTypeTests {
+ "<xs:element name=\"member\" substitutionGroup=\"head\" type=\"xs:int\"/>",
))
}
@Test("Substitution group member is blocked if inline complexType derivation method is final on the head element")
func test_finalSubstitutionExclusionInlineType() {
// Member3 derives by extension from HeadType inline, but Head has final="extension" -> rejected
#expect(!compiles(
"<xs:complexType name=\"HeadType\"><xs:sequence/></xs:complexType>"
+ "<xs:element name=\"Head\" type=\"HeadType\" final=\"extension\"/>"
+ "<xs:element name=\"Member3\" substitutionGroup=\"Head\">"
+ " <xs:complexType><xs:complexContent>"
+ " <xs:extension base=\"HeadType\"><xs:sequence/></xs:extension>"
+ " </xs:complexContent></xs:complexType>"
+ "</xs:element>",
))
// Member3 derives by extension from HeadType inline, Head has final="restriction" -> accepted
#expect(compiles(
"<xs:complexType name=\"HeadType\"><xs:sequence/></xs:complexType>"
+ "<xs:element name=\"Head\" type=\"HeadType\" final=\"restriction\"/>"
+ "<xs:element name=\"Member3\" substitutionGroup=\"Head\">"
+ " <xs:complexType><xs:complexContent>"
+ " <xs:extension base=\"HeadType\"><xs:sequence/></xs:extension>"
+ " </xs:complexContent></xs:complexType>"
+ "</xs:element>",
))
}
@Test("Adversarial Critic: member with inline simpleType restricting another type")
func test_inlineSimpleTypeRestrictingAnotherType() {
#expect(compiles(
"<xs:element name=\"head\" type=\"xs:integer\"/>"
+ "<xs:element name=\"member\" substitutionGroup=\"head\">"
+ " <xs:simpleType>"
+ " <xs:restriction>"
+ " <xs:simpleType>"
+ " <xs:restriction base=\"xs:int\"/>"
+ " </xs:simpleType>"
+ " </xs:restriction>"
+ " </xs:simpleType>"
+ "</xs:element>",
))
}
@Test("Adversarial Critic: member with inline simpleType restricting a named list type")
func test_inlineSimpleTypeRestrictingNamedList() {
#expect(compiles(
"<xs:simpleType name=\"MyList\"><xs:list itemType=\"xs:int\"/></xs:simpleType>"
+ "<xs:element name=\"head\" type=\"xs:anySimpleType\"/>"
+ "<xs:element name=\"member\" substitutionGroup=\"head\">"
+ " <xs:simpleType>"
+ " <xs:restriction base=\"MyList\"/>"
+ " </xs:simpleType>"
+ "</xs:element>",
))
}
}

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 = 1
private let knownSchemaInvalidAccepted = 318
private let knownSchemaInvalidAccepted = 316
private let knownInstanceValidRejected = 171
private let knownInstanceInvalidAccepted = 155

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 -> 4** | production target is 0: rejecting a correct schema is the most user-hostile defect (#148). Particle-restriction over-rejection fixed: pointless `maxOccurs=0` particles removed, content-free restriction of an EMPTY base, empty-derived emptiable-base, and (#163) the pairwise check over the normalized content model with occurrence subsumption applied only to same-kind pairings (cross-kind element/group, group/element, group/wildcard match occurrence inside, by effective total range / member). #164 resolved element-ref namespaces by prefix binding (the `particlesJj/Jk/Jm/Jn/Js`, `particlesQ/R` cluster, -28). Content-free (`maxOccurs=0`) derived groups and substitution-group declaration order closed `particlesW006`/`elemZ027_a`; the W3C MapAndSum rule (Sequence:Choice, #165) closed `particlesV003`. Remaining 1 (`particlesZ001`): a documented spec ambiguity (RecurseAsIfGroup; the reference rejects it), not a defect. |
| **invalid-schemas-accepted** | **2461 -> 318** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion, elementTypeRestrictionOK NameAndTypeOK union checks, prohibited attribute type-check bypass, keyref arity verification, top-level group/attributeGroup local-attribute checks, simpleType restriction structure and prefix binding checks, namespace-aware reference resolution checks, simpleType variety constraints on list itemType and union memberTypes, targetNamespace scope matching on local schema references, substitution group exclusions based on element final and schema finalDefault attributes landed). |
| **invalid-schemas-accepted** | **2461 -> 316** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion, elementTypeRestrictionOK NameAndTypeOK union checks, prohibited attribute type-check bypass, keyref arity verification, top-level group/attributeGroup local-attribute checks, simpleType restriction structure and prefix binding checks, namespace-aware reference resolution checks, simpleType variety constraints on list itemType and union memberTypes, targetNamespace scope matching on local schema references, substitution group exclusions based on element final and schema finalDefault attributes landed). |
| valid-instances-rejected | **233 -> 171** | (instance side; #164's element-ref namespace fix is shared with instance validation, complexContent restriction attributes inheritance and namespace fixes) |
| invalid-instances-accepted | **160 -> 155** | (instance side; resolving inline `<simpleType>` restriction base, complexContent restriction attributes prohibition) |