feat(schema): validate schema-vocabulary attribute values at compile time

The attribute-model sibling of the structural content-model check, folded into
the same schema-document walk. Enumerated attributes (form,
elementFormDefault, attributeFormDefault, use, processContents, mixed, abstract,
nillable) and the occurrence attributes (minOccurs a nonNegativeInteger,
maxOccurs a nonNegativeInteger or unbounded) are checked against their fixed
value space. An out-of-space value (use="foo", mixed="yes", minOccurs="-1") was
previously ignored and the schema accepted.

Each of these names has one meaning throughout XSD, so checking by local name is
safe. Only unprefixed attributes on XSD-namespace elements are checked, so a
foreign attribute is never flagged; values are trimmed before comparison.

XSTS: invalid schemas accepted 1313 to 1190 (-123); valid schemas rejected held
at 72 (no over-rejection); instance buckets unchanged.
This commit is contained in:
Mihaela Mihaljevic 2026-06-13 23:01:42 +02:00
parent 6147666e49
commit 8011f3d2e8
6 changed files with 79 additions and 12 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Schema-vocabulary attribute values are now validated at compile time (#147), the attribute-model sibling of the structural content-model check. Enumerated attributes (`form`/`elementFormDefault`/`attributeFormDefault` to `qualified`/`unqualified`, `use` to `optional`/`prohibited`/`required`, `processContents` to `skip`/`lax`/`strict`, `mixed`/`abstract`/`nillable` to a boolean) and `minOccurs`/`maxOccurs` (a `nonNegativeInteger`, `maxOccurs` also `unbounded`) are checked against their fixed value space; an out-of-space value (`use="foo"`, `mixed="yes"`, `minOccurs="-1"`) was previously ignored and the schema accepted. Only unprefixed attributes on XSD-namespace elements are checked, so a foreign attribute is never flagged. XSTS: invalid schemas accepted 1313 to 1190, no other bucket raised.
- Schema documents are now structurally validated against the schema-for-schemas content model (#147), the general mechanism for the structural tail of the schema-validity gap. A data-driven table of the XSD 1.0 child content model rejects, at compile time, any component child the model does not admit, more than one `annotation` (or an `annotation` that is not first, except on `schema`/`redefine`), and an identity constraint missing its selector or field. The check walks only XSD-namespace elements and skips `xs:appinfo`/`xs:documentation` foreign content, so no valid schema is rejected; `restriction`/`extension` use the permissive union of their contexts (never over-rejecting). One table covers every component rather than a rule hand-coded per element. XSTS: invalid schemas accepted 1688 to 1313 (-375), no other bucket raised.
- The `id` attribute on XSD components is now validated at compile time (#147). It is of type `xs:ID`, so each value must be a valid NCName and unique within the schema document; the values were never checked, so a malformed (`id=""`, `id="123"`) or duplicated `id` left the schema wrongly accepted. The schema-document tree is walked, skipping `xs:appinfo`/`xs:documentation` foreign content (whose `id` attributes are not `xs:ID`) and treating only the unprefixed `id` (not a foreign `xml:id`/`pre:id`) as the component identifier, so no valid schema is rejected. XSTS: invalid schemas accepted 1789 to 1688, no other bucket raised.
- Value-bound facet definitions are now validated at compile time (#147), continuing the facet-validity work. Per XSD Part 2 Datatypes 4.3, each `minInclusive`/`maxInclusive`/`minExclusive`/`maxExclusive` and `enumeration` value must be a valid value of the base type; `minInclusive` excludes `minExclusive` and `maxInclusive` excludes `maxExclusive`; and the lower bound may not exceed the upper (nor equal it when either side is exclusive, an empty value space), compared in the base primitive's value space. XSTS: invalid schemas accepted 2082 to 1789, no other bucket raised.

View File

@ -63,15 +63,61 @@ extension PureXML.Schema.XSDParser {
let local = PureXML.Schema.XSDNode.localName(node)
if local == "appinfo" || local == "documentation" { return }
let children = PureXML.Schema.XSDNode.elementChildren(node)
if node.name?.namespaceURI == xsdNamespace, let local, let allowed = allowedChildren[local] {
let names = children.filter { $0.name?.namespaceURI == xsdNamespace }.compactMap(PureXML.Schema.XSDNode.localName)
errors += childErrors(local: local, children: names, allowed: allowed)
if node.name?.namespaceURI == xsdNamespace {
errors += attributeValueErrors(node)
if let local, let allowed = allowedChildren[local] {
let names = children.filter { $0.name?.namespaceURI == xsdNamespace }.compactMap(PureXML.Schema.XSDNode.localName)
errors += childErrors(local: local, children: names, allowed: allowed)
}
}
for child in children {
collectStructure(child, into: &errors)
}
}
/// The enumerated value space of the schema-vocabulary attributes that take a
/// fixed set of tokens. Each name has one meaning throughout XSD, so checking
/// by local name is safe; `minOccurs`/`maxOccurs` are handled numerically.
private static let attributeEnumerations: [String: Set<String>] = [
"form": ["qualified", "unqualified"],
"elementFormDefault": ["qualified", "unqualified"],
"attributeFormDefault": ["qualified", "unqualified"],
"use": ["optional", "prohibited", "required"],
"processContents": ["skip", "lax", "strict"],
"mixed": ["true", "false", "1", "0"],
"abstract": ["true", "false", "1", "0"],
"nillable": ["true", "false", "1", "0"],
]
/// Findings for the unprefixed schema-vocabulary attributes on `node` whose
/// value falls outside its fixed value space: an enumerated attribute with an
/// unknown token, or `minOccurs`/`maxOccurs` that is not a `nonNegativeInteger`
/// (`maxOccurs` also admits `unbounded`). A prefixed (foreign) attribute is the
/// schema author's own and is not checked.
private static func attributeValueErrors(_ node: XSDTree) -> [String] {
var errors: [String] = []
for attribute in node.attributes where attribute.name.prefix == nil {
let name = attribute.name.localName
let value = attribute.value.trimmingXMLWhitespace()
if let allowed = attributeEnumerations[name], !allowed.contains(value) {
errors.append("attribute '\(name)' has invalid value '\(attribute.value)'")
} else if name == "minOccurs", !isNonNegativeInteger(value) {
errors.append("attribute 'minOccurs' must be a nonNegativeInteger, not '\(attribute.value)'")
} else if name == "maxOccurs", value != "unbounded", !isNonNegativeInteger(value) {
errors.append("attribute 'maxOccurs' must be a nonNegativeInteger or 'unbounded', not '\(attribute.value)'")
}
}
return errors
}
/// Whether `value` is a lexical `nonNegativeInteger` (optional `+`, ASCII
/// digits), independent of machine-integer range.
private static func isNonNegativeInteger(_ value: String) -> Bool {
var digits = Substring(value)
if digits.first == "+" { digits = digits.dropFirst() }
return !digits.isEmpty && digits.allSatisfy { $0.isASCII && $0.isNumber }
}
private static func childErrors(local: String, children: [String], allowed: Set<String>) -> [String] {
var errors: [String] = []
for name in children where !allowed.contains(name) {

View File

@ -74,6 +74,30 @@ struct SchemaStructureTests {
"""#)
}
@Test("enumerated attributes accept only their value space")
func test_enumeratedAttributeValues() {
#expect(rejects(#"<xs:attribute name="a" type="xs:string" use="foo"/>"#))
#expect(rejects(#"<xs:attribute name="a" type="xs:string" use=""/>"#))
#expect(rejects(#"<xs:complexType name="t" mixed="yes"><xs:sequence/></xs:complexType>"#))
#expect(rejects(#"<xs:complexType name="t" abstract="maybe"><xs:sequence/></xs:complexType>"#))
#expect(rejects(#"<xs:element name="a" type="xs:string" form="Qualified"/>"#))
}
@Test("minOccurs and maxOccurs must be nonNegativeInteger (maxOccurs also unbounded)")
func test_occursValues() throws {
try compile(#"<xs:complexType name="t"><xs:sequence><xs:element name="a" type="xs:string" minOccurs="0" maxOccurs="unbounded"/></xs:sequence></xs:complexType>"#)
#expect(rejects(#"<xs:complexType name="t"><xs:sequence><xs:element name="a" type="xs:string" minOccurs="-1"/></xs:sequence></xs:complexType>"#))
#expect(rejects(#"<xs:complexType name="t"><xs:sequence><xs:element name="a" type="xs:string" maxOccurs="lots"/></xs:sequence></xs:complexType>"#))
#expect(rejects(#"<xs:complexType name="t"><xs:sequence><xs:element name="a" type="xs:string" minOccurs="x"/></xs:sequence></xs:complexType>"#))
}
@Test("valid enumerated and occurrence attribute values compile")
func test_validAttributeValues() throws {
try compile(#"<xs:attribute name="a" type="xs:string" use="required"/>"#)
try compile(#"<xs:complexType name="t" mixed="true"><xs:sequence/></xs:complexType>"#)
try compile(#"<xs:complexType name="t" abstract="false"><xs:sequence/></xs:complexType>"#)
}
@Test("an identity constraint requires a selector and field")
func test_identityConstraintRequiresParts() {
#expect(rejects(#"""

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 = 1313
private let knownSchemaInvalidAccepted = 1190
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 -> 1313** | we rarely catch a bad one (facet, id, and structural content-model validity landed) |
| **invalid-schemas-accepted** | **2461 -> 1190** | we rarely catch a bad one (facet, id, and structural content-model validity landed) |
| valid-instances-rejected | 233 | (instance side, separately tracked) |
| invalid-instances-accepted | 165 | |
@ -73,11 +73,7 @@ Each iteration targets one self-contained rule family with a clean root cause.
(1688 -> 1313, -375)._ A data-driven `allowedChildren` table (the schema-for-
schemas child content model) rejects disallowed children, multi/misplaced
`annotation`, and identity constraints missing selector/field, via one general
mechanism (`SchemaStructure.swift`). **Still open:** the **attribute model**
(which attributes each component admits, and their value types) is the obvious
next sibling table and the largest remaining lever; child *order* beyond the
leading annotation; and particle / model-group semantic rules (Unique Particle
Attribution, Particle-Valid-Restriction completeness).
mechanism (`SchemaStructure.swift`). Attribute **value** validity also landed (enumerated attributes, `minOccurs`/`maxOccurs`; 1313 -> 1190). **Still open:** attribute **applicability** (which attributes each component admits, e.g. `form` is prohibited on a global declaration, `ref` excludes `name`); child *order* beyond the leading annotation; and particle / model-group semantic rules (Unique Particle Attribution, Particle-Valid-Restriction completeness).
## Per-iteration protocol (the PR-critic-loop)

View File

@ -47,7 +47,7 @@ and to document the remainder as spec-justified, deliberate exclusions.
## Category A: genuine correctness bugs
### Fixed (eighteen root causes, ~1822 deviations cleared)
### Fixed (nineteen root causes, ~1945 deviations cleared)
list-datatype length facets (#146) · union pattern/enumeration facets · duration
partial-order facets · QName length non-constraining · the full XSD `\p{Is...}`
@ -65,7 +65,7 @@ validity (`min`/`maxInclusive`, `min`/`maxExclusive`, `enumeration` values valid
in the base space; inclusive/exclusive exclusions; bound ordering) · `gMonth`
`--MM--` lexical form · schema `id` attribute validity (`xs:ID`: NCName and
unique within the document). The schema-validity work brought invalid schemas
accepted 2461 to 1313 (also: schema-document structural validity against the schema-for-schemas content model); see `schema-validity-burndown.md`.
accepted 2461 to 1190 (also: schema-document structural validity, content model and attribute values, against the schema-for-schemas); see `schema-validity-burndown.md`.
Plus the measurement fix (exclude W3C-disputed `status="queried"` entries).
### Remaining, root-caused (the hard tail)