diff --git a/CHANGELOG.md b/CHANGELOG.md index e67da9f..57c0d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index 546de5a..8aa5628 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -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] = [ + "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] { var errors: [String] = [] for name in children where !allowed.contains(name) { diff --git a/Tests/SchemaStructureTests.swift b/Tests/SchemaStructureTests.swift index 470a18f..e561b55 100644 --- a/Tests/SchemaStructureTests.swift +++ b/Tests/SchemaStructureTests.swift @@ -74,6 +74,30 @@ struct SchemaStructureTests { """#) } + @Test("enumerated attributes accept only their value space") + func test_enumeratedAttributeValues() { + #expect(rejects(#""#)) + #expect(rejects(#""#)) + #expect(rejects(#""#)) + #expect(rejects(#""#)) + #expect(rejects(#""#)) + } + + @Test("minOccurs and maxOccurs must be nonNegativeInteger (maxOccurs also unbounded)") + func test_occursValues() throws { + try compile(#""#) + #expect(rejects(#""#)) + #expect(rejects(#""#)) + #expect(rejects(#""#)) + } + + @Test("valid enumerated and occurrence attribute values compile") + func test_validAttributeValues() throws { + try compile(#""#) + try compile(#""#) + try compile(#""#) + } + @Test("an identity constraint requires a selector and field") func test_identityConstraintRequiresParts() { #expect(rejects(#""" diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index d33c184..1d9d398 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -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 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index b7b2b4e..d4c0019 100644 --- a/docs/schema-validity-burndown.md +++ b/docs/schema-validity-burndown.md @@ -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) diff --git a/docs/xsts-deviations.md b/docs/xsts-deviations.md index ffa3be3..bd96d05 100644 --- a/docs/xsts-deviations.md +++ b/docs/xsts-deviations.md @@ -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)