From ef80934ff81a8fd9c55b878c804f34f2ce50f976 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 15 Jun 2026 17:06:09 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../Schema/SchemaAttributeApplicability.swift | 16 +- Sources/Schema/SchemaFacetApplicability.swift | 35 +++++ Sources/Schema/SchemaStructure.swift | 1 + Sources/Schema/XSDSimpleParser.swift | 83 ++++++---- Tests/SchemaSimpleTypeRestrictionTests.swift | 145 ++++++++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- docs/schema-validity-burndown.md | 2 +- 8 files changed, 251 insertions(+), 34 deletions(-) create mode 100644 Tests/SchemaSimpleTypeRestrictionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0e10d..5d1c805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Schema/SchemaAttributeApplicability.swift b/Sources/Schema/SchemaAttributeApplicability.swift index ffdea32..f02ce9b 100644 --- a/Sources/Schema/SchemaAttributeApplicability.swift +++ b/Sources/Schema/SchemaAttributeApplicability.swift @@ -115,14 +115,22 @@ extension PureXML.Schema.XSDParser { "element": ["ref", "form", "minOccurs", "maxOccurs"], ] + private static let namedTopLevelKinds: Set = [ + "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)'") + } } } } diff --git a/Sources/Schema/SchemaFacetApplicability.swift b/Sources/Schema/SchemaFacetApplicability.swift index a1f2486..a9a8823 100644 --- a/Sources/Schema/SchemaFacetApplicability.swift +++ b/Sources/Schema/SchemaFacetApplicability.swift @@ -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) + } + } } diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index dc9cea0..1f3f188 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -60,6 +60,7 @@ extension PureXML.Schema.XSDParser { errors += valueConstraintErrors(schema) errors += topLevelDeclarationErrors(schema) errors += nestedNamedDefinitionErrors(schema) + errors += anySimpleTypeRestrictionErrors(schema) return errors } diff --git a/Sources/Schema/XSDSimpleParser.swift b/Sources/Schema/XSDSimpleParser.swift index 3c9bc38..daaf803 100644 --- a/Sources/Schema/XSDSimpleParser.swift +++ b/Sources/Schema/XSDSimpleParser.swift @@ -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 } } } diff --git a/Tests/SchemaSimpleTypeRestrictionTests.swift b/Tests/SchemaSimpleTypeRestrictionTests.swift new file mode 100644 index 0000000..22ea944 --- /dev/null +++ b/Tests/SchemaSimpleTypeRestrictionTests.swift @@ -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(""" + + \(body) + + """) + } + + 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(""" + + + + """)) + } + + @Test("extending xs:anySimpleType under simpleContent is accepted") + func test_anySimpleTypeSimpleContent() throws { + try compile(""" + + + + + + """) + } + + @Test("using xs:anySimpleType as list itemType or union memberTypes is accepted") + func test_anySimpleTypeInListOrUnion() throws { + try compile(""" + + + + + + + """) + } + + @Test("top-level anonymous definitions are rejected") + func test_topLevelUnnamed() { + #expect(rejects(""" + + + + + + """)) + #expect(rejects(""" + + + + + + """)) + } + + @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(""" + + + + + + + + + + + """)) + + // totalDigits: base has 4, restriction has 4 -> accepted + try compile(""" + + + + + + + + + + + """) + + // fractionDigits: base has 2, restriction has 3 -> rejected + #expect(rejects(""" + + + + + + + + + + + """)) + + // minLength/maxLength/length restrictions + #expect(rejects(""" + + + + + + + + + + + """)) + } + + @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) + } + } + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 7a8dca6..1ccb1bf 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 = 1 - private let knownSchemaInvalidAccepted = 387 + private let knownSchemaInvalidAccepted = 383 private let knownInstanceValidRejected = 180 private let knownInstanceInvalidAccepted = 158 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index 0fc02c8..393fa54 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 -> 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 `` restriction base preserves list/union variety, so a `length` facet counts items and two more invalid instances are caught) |