fix(schema): validate xml:lang and xml:space on documentation elements

This commit is contained in:
Mihaela Mihaljevic 2026-06-15 22:36:15 +02:00
parent 20b97d1d62
commit 926ff99b5a
6 changed files with 143 additions and 78 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Validate standard `xml:lang` and `xml:space` attributes on XSD schema elements (like `<xs:documentation>` and `<xs:appinfo>`). Traversal is adjusted to process attributes on these documentation-related elements before skipping their foreign children, and validation checks the lexical pattern of `xml:lang` (using BCP 47) and restricts `xml:space` to 'default' or 'preserve'. XSTS: invalid schemas accepted 306 to 304, no other bucket raised.
- Validate notation declarations and notation enumeration constraints on simpleTypes (XSD 1.0 Structures §3.12 / §3.14). Every `<xs:notation>` declaration must specify at least one of `public` or `system` attributes. For any simpleType restricting `xs:NOTATION`, all `enumeration` values must name a declared notation in the schema. Resolved prefix bindings are traversed recursively up the ancestor path to handle default namespaces and local namespace declarations. XSTS: invalid schemas accepted 309 to 306, no other bucket raised.
- Enforce cross-container global component-name and identity-constraint uniqueness constraints (XSD 1.0 Structures §3.14). Within a schema (including all included and imported schemas), global type names (simpleType and complexType sharing one symbol space), global element names, global attribute names, named model groups, and named attribute groups must be unique within their respective symbol spaces and target namespaces. Identity constraints (unique, key, keyref) must have unique names across all target namespaces in the entire schema document. Circular and duplicate imports/includes are handled namespace-aware without causing false-positive validations on valid schemas. XSTS: invalid schemas accepted 316 to 309, 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.

View File

@ -67,7 +67,6 @@ extension PureXML.Schema.XSDParser {
private static func collectStructure(_ node: XSDTree, bindings: [String: String], into errors: inout [String]) {
let local = PureXML.Schema.XSDNode.localName(node)
if local == "appinfo" || local == "documentation" { return }
let children = PureXML.Schema.XSDNode.elementChildren(node)
var currentBindings = bindings
for attribute in node.attributes {
@ -84,6 +83,7 @@ extension PureXML.Schema.XSDParser {
let names = children.filter { $0.name?.namespaceURI == xsdNamespace }.compactMap(PureXML.Schema.XSDNode.localName)
errors += componentSpecificErrors(node, local: local, names: names)
}
if local == "appinfo" || local == "documentation" { return }
for child in children {
collectStructure(child, bindings: currentBindings, into: &errors)
}
@ -150,17 +150,28 @@ extension PureXML.Schema.XSDParser {
/// (`maxOccurs` also admits `unbounded`). A prefixed (foreign) attribute is the
/// schema author's own and is not checked.
private static func attributeValueErrors(_ node: XSDTree, bindings: [String: String]) -> [String] {
node.attributes.filter { $0.name.prefix == nil }.compactMap { attribute in
node.attributes.compactMap { attribute -> String? in
let prefix = attribute.name.prefix
let name = attribute.name.localName
let value = attribute.value
if let err = attributeValueError(name, value) {
return err
}
if qnameAttributes.contains(name) {
return checkQNamePrefix(value, bindings: bindings)
}
if name == "memberTypes" {
return checkMemberTypesPrefixes(value, bindings: bindings)
if prefix == nil {
if let err = attributeValueError(name, value) {
return err
}
if qnameAttributes.contains(name) {
return checkQNamePrefix(value, bindings: bindings)
}
if name == "memberTypes" {
return checkMemberTypesPrefixes(value, bindings: bindings)
}
} else if prefix == "xml" {
if name == "lang" {
let trimmed = value.trimmingXMLWhitespace()
return PureXML.Schema.Lexical.isLanguage(trimmed) ? nil : "attribute 'xml:lang' has invalid value '\(value)'"
} else if name == "space" {
let trimmed = value.trimmingXMLWhitespace()
return (trimmed == "default" || trimmed == "preserve") ? nil : "attribute 'xml:space' has invalid value '\(value)'"
}
}
return nil
}

View File

@ -0,0 +1,119 @@
@testable import PureXML
import Testing
@Suite("Schema structural derivation validity")
struct SchemaStructureDerivationTests {
private func compile(_ body: String) throws {
_ = try PureXML.Schema.Document("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
\(body)
</xs:schema>
""")
}
private func rejects(_ body: String) -> Bool {
do {
try compile(body)
return false
} catch {
return true
}
}
@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>
"""#)
}
@Test("a complexType's shorthand content must be ordered: model group, attributes, anyAttribute")
func test_complexTypeShorthandOrder() {
// A model group after an attribute, two anyAttributes, an attribute after anyAttribute (ctB).
#expect(rejects(#"<xs:complexType name="t"><xs:attribute name="x"/><xs:sequence/></xs:complexType>"#))
#expect(rejects(#"<xs:complexType name="t"><xs:all/><xs:anyAttribute/><xs:anyAttribute/></xs:complexType>"#))
#expect(rejects(#"<xs:complexType name="t"><xs:anyAttribute/><xs:attribute name="x"/></xs:complexType>"#))
// A well-ordered shorthand complexType compiles.
#expect(!rejects(#"""
<xs:complexType name="t">
<xs:sequence><xs:element name="e" type="xs:string"/></xs:sequence>
<xs:attribute name="x" type="xs:string"/>
<xs:anyAttribute/>
</xs:complexType>
"""#))
}
@Test("a simpleType must contain exactly one of restriction, list, or union")
func test_simpleTypeContent() {
// Two derivations, or a mix, is invalid (stB).
#expect(rejects(#"<xs:simpleType name="t"><xs:list itemType="xs:int"/><xs:list itemType="xs:int"/></xs:simpleType>"#))
#expect(rejects(#"<xs:simpleType name="t"><xs:union memberTypes="xs:int"/><xs:restriction base="xs:string"/></xs:simpleType>"#))
#expect(rejects(#"<xs:simpleType name="t"></xs:simpleType>"#))
// Exactly one compiles.
#expect(!rejects(#"<xs:simpleType name="t"><xs:restriction base="xs:string"/></xs:simpleType>"#))
#expect(!rejects(#"<xs:simpleType name="t"><xs:list itemType="xs:int"/></xs:simpleType>"#))
}
@Test("documentation xml:lang and xml:space validation")
func test_documentationXmlAttributes() throws {
// Valid xml:lang and xml:space
try compile(#"""
<xs:annotation>
<xs:documentation xml:lang="en" xml:space="preserve">Valid documentation</xs:documentation>
</xs:annotation>
"""#)
// Invalid xml:lang (empty)
#expect(rejects(#"""
<xs:annotation>
<xs:documentation xml:lang="">Invalid documentation</xs:documentation>
</xs:annotation>
"""#))
// Invalid xml:lang (whitespace only)
#expect(rejects(#"""
<xs:annotation>
<xs:documentation xml:lang=" ">Invalid documentation</xs:documentation>
</xs:annotation>
"""#))
// Invalid xml:space
#expect(rejects(#"""
<xs:annotation>
<xs:documentation xml:space="invalid">Invalid documentation</xs:documentation>
</xs:annotation>
"""#))
}
}

View File

@ -234,70 +234,4 @@ 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>
"""#)
}
@Test("a complexType's shorthand content must be ordered: model group, attributes, anyAttribute")
func test_complexTypeShorthandOrder() {
// A model group after an attribute, two anyAttributes, an attribute after anyAttribute (ctB).
#expect(rejects(#"<xs:complexType name="t"><xs:attribute name="x"/><xs:sequence/></xs:complexType>"#))
#expect(rejects(#"<xs:complexType name="t"><xs:all/><xs:anyAttribute/><xs:anyAttribute/></xs:complexType>"#))
#expect(rejects(#"<xs:complexType name="t"><xs:anyAttribute/><xs:attribute name="x"/></xs:complexType>"#))
// A well-ordered shorthand complexType compiles.
#expect(!rejects(#"""
<xs:complexType name="t">
<xs:sequence><xs:element name="e" type="xs:string"/></xs:sequence>
<xs:attribute name="x" type="xs:string"/>
<xs:anyAttribute/>
</xs:complexType>
"""#))
}
@Test("a simpleType must contain exactly one of restriction, list, or union")
func test_simpleTypeContent() {
// Two derivations, or a mix, is invalid (stB).
#expect(rejects(#"<xs:simpleType name="t"><xs:list itemType="xs:int"/><xs:list itemType="xs:int"/></xs:simpleType>"#))
#expect(rejects(#"<xs:simpleType name="t"><xs:union memberTypes="xs:int"/><xs:restriction base="xs:string"/></xs:simpleType>"#))
#expect(rejects(#"<xs:simpleType name="t"></xs:simpleType>"#))
// Exactly one compiles.
#expect(!rejects(#"<xs:simpleType name="t"><xs:restriction base="xs:string"/></xs:simpleType>"#))
#expect(!rejects(#"<xs:simpleType name="t"><xs:list itemType="xs:int"/></xs:simpleType>"#))
}
}

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 = 306
private let knownSchemaInvalidAccepted = 304
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 -> 306** | 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, cross-container component-name and identity-constraint uniqueness checks, notation declarations and notation enumeration restrictions landed). |
| **invalid-schemas-accepted** | **2461 -> 304** | 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, cross-container component-name and identity-constraint uniqueness checks, notation declarations and notation enumeration restrictions, xml:lang and xml:space on documentation/appinfo elements checks 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) |