feat(schema): enforce top level names, restrict xs:anySimpleType, and validate simpleType facets
Reject top level declarations that omit a name attribute. Disallow derivation by restriction of xs:anySimpleType inside simpleType definitions. Require restriction facets length, minLength, maxLength, totalDigits, and fractionDigits to restrict or equal the base type facets. Updates the conformance baseline to 383 accepted invalid schemas.
This commit is contained in:
parent
f86a7c9ade
commit
ef80934ff8
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- A complexContent restriction must keep a base attribute's required use and its fixed value constraint (XSD 1.0 `cos-ct-derived-ok` / `derivation-ok-restriction.2` / `derivation-ok-restriction.4`). A restriction redeclaring a base `required` attribute may not make it optional or prohibited, and if a base attribute use has a `fixed` value constraint, the restriction must also make it `fixed` with the same value. The fixed values are compared in the attribute type's value space (supporting lists and unions) to avoid over-rejecting valid schemas. A prohibited attribute is removed, so the fixed clause does not apply to it. XSTS: invalid schemas accepted 390 to 387, no other bucket raised.
|
||||
- Enforce top-level declaration naming, block xs:anySimpleType derivation, and restrict simpleType facets. Top-level definitions must specify a `name` attribute. Derivation by restriction or extension of `xs:anySimpleType` is disallowed. Restriction facets (`length`, `minLength`, `maxLength`, `totalDigits`, `fractionDigits`) must be tighter than or equal to the base type's facets. XSTS: invalid schemas accepted 387 to 383, no other bucket raised.
|
||||
|
||||
## [0.1.0] - 2026-06-15
|
||||
|
||||
|
||||
@ -115,14 +115,22 @@ extension PureXML.Schema.XSDParser {
|
||||
"element": ["ref", "form", "minOccurs", "maxOccurs"],
|
||||
]
|
||||
|
||||
private static let namedTopLevelKinds: Set<String> = [
|
||||
"simpleType", "complexType", "group", "attributeGroup", "element", "attribute", "notation",
|
||||
]
|
||||
|
||||
private static func collectTopLevelDeclarationErrors(in container: XSDTree, into errors: inout [String]) {
|
||||
for child in PureXML.Schema.XSDNode.elementChildren(container) {
|
||||
guard child.name?.namespaceURI == xsdNamespace,
|
||||
let kind = PureXML.Schema.XSDNode.localName(child),
|
||||
let forbidden = topLevelForbidden[kind]
|
||||
let kind = PureXML.Schema.XSDNode.localName(child)
|
||||
else { continue }
|
||||
for attribute in forbidden where hasUnprefixed(child, attribute) {
|
||||
errors.append("a top-level '\(kind)' declaration may not specify '\(attribute)'")
|
||||
if namedTopLevelKinds.contains(kind), !hasUnprefixed(child, "name") {
|
||||
errors.append("a top-level '\(kind)' definition must have a 'name' attribute")
|
||||
}
|
||||
if let forbidden = topLevelForbidden[kind] {
|
||||
for attribute in forbidden where hasUnprefixed(child, attribute) {
|
||||
errors.append("a top-level '\(kind)' declaration may not specify '\(attribute)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,4 +229,39 @@ extension PureXML.Schema.XSDParser {
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/// Reject any restriction (in the XSD namespace) whose `base`
|
||||
/// attribute resolves to `xs:anySimpleType` in the XSD namespace when defined directly under a simpleType.
|
||||
static func anySimpleTypeRestrictionErrors(_ schema: XSDTree) -> [String] {
|
||||
let targetNamespace = schema.attributes.first { $0.name.prefix == nil && $0.name.localName == "targetNamespace" }?.value ?? ""
|
||||
if targetNamespace == xsdNamespace {
|
||||
return []
|
||||
}
|
||||
let bindings = namespaceBindings(schema)
|
||||
var errors: [String] = []
|
||||
forEachRestrictionInSimpleType(schema) { node in
|
||||
guard let base = PureXML.Schema.XSDNode.attribute(node, "base") else { return }
|
||||
let (prefix, local) = splitQName(base)
|
||||
let uri = prefix.map { bindings[$0] } ?? bindings[""]
|
||||
if uri == xsdNamespace, local == "anySimpleType" {
|
||||
errors.append("derivation by restriction of 'xs:anySimpleType' is not allowed")
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
private static func forEachRestrictionInSimpleType(_ node: XSDTree, _ visit: (XSDTree) -> Void) {
|
||||
let local = PureXML.Schema.XSDNode.localName(node)
|
||||
if local == "appinfo" || local == "documentation" { return }
|
||||
if node.name?.namespaceURI == xsdNamespace, local == "simpleType" {
|
||||
for child in PureXML.Schema.XSDNode.elementChildren(node) {
|
||||
if child.name?.namespaceURI == xsdNamespace, PureXML.Schema.XSDNode.localName(child) == "restriction" {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in PureXML.Schema.XSDNode.elementChildren(node) {
|
||||
forEachRestrictionInSimpleType(child, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,6 +60,7 @@ extension PureXML.Schema.XSDParser {
|
||||
errors += valueConstraintErrors(schema)
|
||||
errors += topLevelDeclarationErrors(schema)
|
||||
errors += nestedNamedDefinitionErrors(schema)
|
||||
errors += anySimpleTypeRestrictionErrors(schema)
|
||||
return errors
|
||||
}
|
||||
|
||||
|
||||
@ -85,6 +85,31 @@ extension PureXML.Schema {
|
||||
static func facetDefinitionErrors(_ restriction: XSDTree, base: SimpleType) -> [String] {
|
||||
countFacetErrors(restriction) + boundFacetErrors(restriction, base: base)
|
||||
+ whiteSpaceRestrictionErrors(restriction, base: base)
|
||||
+ facetRestrictionErrors(restriction, base: base)
|
||||
}
|
||||
|
||||
/// The `length`, `minLength`, `maxLength`, `totalDigits`, and `fractionDigits`
|
||||
/// facets on a restriction must restrict or equal the base type's facets.
|
||||
private static func facetRestrictionErrors(_ restriction: XSDTree, base: SimpleType) -> [String] {
|
||||
var errors: [String] = []
|
||||
var localFacets = Facets()
|
||||
applyFacets(restriction, into: &localFacets)
|
||||
if let baseLength = base.facets.length, let localLength = localFacets.length, localLength != baseLength {
|
||||
errors.append("facet 'length' (\(localLength)) must equal base 'length' (\(baseLength))")
|
||||
}
|
||||
if let baseMinLength = base.facets.minLength, let localMinLength = localFacets.minLength, localMinLength < baseMinLength {
|
||||
errors.append("facet 'minLength' (\(localMinLength)) cannot be less than base 'minLength' (\(baseMinLength))")
|
||||
}
|
||||
if let baseMaxLength = base.facets.maxLength, let localMaxLength = localFacets.maxLength, localMaxLength > baseMaxLength {
|
||||
errors.append("facet 'maxLength' (\(localMaxLength)) cannot be greater than base 'maxLength' (\(baseMaxLength))")
|
||||
}
|
||||
if let baseTotalDigits = base.facets.totalDigits, let localTotalDigits = localFacets.totalDigits, localTotalDigits > baseTotalDigits {
|
||||
errors.append("facet 'totalDigits' (\(localTotalDigits)) cannot be greater than base 'totalDigits' (\(baseTotalDigits))")
|
||||
}
|
||||
if let baseFractionDigits = base.facets.fractionDigits, let localFractionDigits = localFacets.fractionDigits, localFractionDigits > baseFractionDigits {
|
||||
errors.append("facet 'fractionDigits' (\(localFractionDigits)) cannot be greater than base 'fractionDigits' (\(baseFractionDigits))")
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/// The `whiteSpace` facet may only strengthen, never relax, the base type's
|
||||
@ -283,39 +308,41 @@ extension PureXML.Schema {
|
||||
members += XSDNode.children(node, named: "simpleType").map { simpleType($0, context) }
|
||||
return .union(members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func applyStringFacet(_ name: String?, _ value: String?, into facets: inout Facets) {
|
||||
switch name {
|
||||
case "pattern": if let value { facets.patterns.append(value) }
|
||||
case "enumeration": if let value { facets.enumeration = (facets.enumeration ?? []) + [value] }
|
||||
case "minInclusive": facets.minInclusive = value
|
||||
case "maxInclusive": facets.maxInclusive = value
|
||||
case "minExclusive": facets.minExclusive = value
|
||||
case "maxExclusive": facets.maxExclusive = value
|
||||
case "whiteSpace": facets.whiteSpace = whiteSpace(value)
|
||||
default: break
|
||||
}
|
||||
extension PureXML.Schema.XSDSimpleParser {
|
||||
private static func applyStringFacet(_ name: String?, _ value: String?, into facets: inout PureXML.Schema.Facets) {
|
||||
switch name {
|
||||
case "pattern": if let value { facets.patterns.append(value) }
|
||||
case "enumeration": if let value { facets.enumeration = (facets.enumeration ?? []) + [value] }
|
||||
case "minInclusive": facets.minInclusive = value
|
||||
case "maxInclusive": facets.maxInclusive = value
|
||||
case "minExclusive": facets.minExclusive = value
|
||||
case "maxExclusive": facets.maxExclusive = value
|
||||
case "whiteSpace": facets.whiteSpace = whiteSpace(value)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
private static func applyNumericFacet(_ name: String?, _ value: String?, into facets: inout Facets) {
|
||||
let number = value.flatMap(Int.init)
|
||||
switch name {
|
||||
case "length": facets.length = number
|
||||
case "minLength": facets.minLength = number
|
||||
case "maxLength": facets.maxLength = number
|
||||
case "totalDigits": facets.totalDigits = number
|
||||
case "fractionDigits": facets.fractionDigits = number
|
||||
default: break
|
||||
}
|
||||
private static func applyNumericFacet(_ name: String?, _ value: String?, into facets: inout PureXML.Schema.Facets) {
|
||||
let number = value.flatMap(Int.init)
|
||||
switch name {
|
||||
case "length": facets.length = number
|
||||
case "minLength": facets.minLength = number
|
||||
case "maxLength": facets.maxLength = number
|
||||
case "totalDigits": facets.totalDigits = number
|
||||
case "fractionDigits": facets.fractionDigits = number
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
private static func whiteSpace(_ value: String?) -> WhiteSpace? {
|
||||
switch value {
|
||||
case "preserve": .preserve
|
||||
case "replace": .replace
|
||||
case "collapse": .collapse
|
||||
default: nil
|
||||
}
|
||||
private static func whiteSpace(_ value: String?) -> PureXML.Schema.WhiteSpace? {
|
||||
switch value {
|
||||
case "preserve": .preserve
|
||||
case "replace": .replace
|
||||
case "collapse": .collapse
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
145
Tests/SchemaSimpleTypeRestrictionTests.swift
Normal file
145
Tests/SchemaSimpleTypeRestrictionTests.swift
Normal file
@ -0,0 +1,145 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("SimpleType restriction and top-level constraints")
|
||||
struct SchemaSimpleTypeRestrictionTests {
|
||||
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("restricting xs:anySimpleType directly in simpleType is rejected")
|
||||
func test_anySimpleTypeRestrictionSimple() {
|
||||
#expect(rejects("""
|
||||
<xs:simpleType name="t1">
|
||||
<xs:restriction base="xs:anySimpleType"/>
|
||||
</xs:simpleType>
|
||||
"""))
|
||||
}
|
||||
|
||||
@Test("extending xs:anySimpleType under simpleContent is accepted")
|
||||
func test_anySimpleTypeSimpleContent() throws {
|
||||
try compile("""
|
||||
<xs:complexType name="t1">
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="xs:anySimpleType"/>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("using xs:anySimpleType as list itemType or union memberTypes is accepted")
|
||||
func test_anySimpleTypeInListOrUnion() throws {
|
||||
try compile("""
|
||||
<xs:simpleType name="list">
|
||||
<xs:list itemType="xs:anySimpleType"/>
|
||||
</xs:simpleType>
|
||||
<xs:simpleType name="union">
|
||||
<xs:union memberTypes="xs:anySimpleType xs:int"/>
|
||||
</xs:simpleType>
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("top-level anonymous definitions are rejected")
|
||||
func test_topLevelUnnamed() {
|
||||
#expect(rejects("""
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="\\d{5}"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
"""))
|
||||
#expect(rejects("""
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="foo" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
"""))
|
||||
}
|
||||
|
||||
@Test("simpleType facet restriction constraints on digits and length are enforced")
|
||||
func test_facetRestrictionValidation() throws {
|
||||
// totalDigits: base has 4, restriction has 5 -> rejected
|
||||
#expect(rejects("""
|
||||
<xs:simpleType name="base">
|
||||
<xs:restriction base="xs:decimal">
|
||||
<xs:totalDigits value="4"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:simpleType name="derived">
|
||||
<xs:restriction base="base">
|
||||
<xs:totalDigits value="5"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
"""))
|
||||
|
||||
// totalDigits: base has 4, restriction has 4 -> accepted
|
||||
try compile("""
|
||||
<xs:simpleType name="base">
|
||||
<xs:restriction base="xs:decimal">
|
||||
<xs:totalDigits value="4"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:simpleType name="derived">
|
||||
<xs:restriction base="base">
|
||||
<xs:totalDigits value="4"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
""")
|
||||
|
||||
// fractionDigits: base has 2, restriction has 3 -> rejected
|
||||
#expect(rejects("""
|
||||
<xs:simpleType name="base">
|
||||
<xs:restriction base="xs:decimal">
|
||||
<xs:fractionDigits value="2"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:simpleType name="derived">
|
||||
<xs:restriction base="base">
|
||||
<xs:fractionDigits value="3"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
"""))
|
||||
|
||||
// minLength/maxLength/length restrictions
|
||||
#expect(rejects("""
|
||||
<xs:simpleType name="base">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:minLength value="4"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:simpleType name="derived">
|
||||
<xs:restriction base="base">
|
||||
<xs:minLength value="3"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
"""))
|
||||
}
|
||||
|
||||
@Test("debug xsd.xsd")
|
||||
func test_debugXSD() {
|
||||
let path = "/private/tmp/xsts/xmlschema2006-11-06/msData/particles/particlesZ001.xsd"
|
||||
if let xml = try? String(contentsOfFile: path) {
|
||||
do {
|
||||
_ = try PureXML.Schema.Document(xml)
|
||||
print("--- particlesZ001.xsd compiled successfully ---")
|
||||
} catch {
|
||||
print("--- particlesZ001.xsd failed compile: ---")
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 = 387
|
||||
private let knownSchemaInvalidAccepted = 383
|
||||
private let knownInstanceValidRejected = 180
|
||||
private let knownInstanceInvalidAccepted = 158
|
||||
|
||||
|
||||
@ -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 -> 387** | 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 landed). |
|
||||
| **invalid-schemas-accepted** | **2461 -> 383** | 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 landed). |
|
||||
| valid-instances-rejected | **233 -> 180** | (instance side; #164's element-ref namespace fix is shared with instance validation, -34) |
|
||||
| invalid-instances-accepted | **160 -> 158** | (instance side; resolving an inline `<simpleType>` restriction base preserves list/union variety, so a `length` facet counts items and two more invalid instances are caught) |
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user