From c83e26a2737617e344a6620826ed9eabefd5480a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 22 Jun 2026 18:14:50 +0200 Subject: [PATCH] fix(schema): optional xs:all group requires its members when present An xs:all group's own minOccurs="0" makes the GROUP optional (it occurs zero or one times), not each member optional. The parser forced every member's minOccurs to 0 whenever the all-group was optional, so a non-empty but incomplete optional all-group (a required member missing) was wrongly accepted, and a complexContent restriction that relaxed such a required member also slipped past the particle-restriction check (the zeroed minOccurs made it look already optional). Members now keep their declared occurrence; the absent-group case is handled at validation time by skipping the required-member check only when the all-group contributes no children at all. Applied identically on the tree and streaming paths (both call allStructureErrors). A required all-group is unchanged. Catches mgZ001 (instance) and particlesK006 (schema). XSTS invalid- instances-accepted 21 to 20 and invalid-schemas-accepted 18 to 17, both valid- rejected buckets held at 0. --- CHANGELOG.md | 1 + Sources/Schema/ComplexValidator.swift | 2 +- .../Schema/ComplexValidatorContentModel.swift | 7 ++- .../Schema/ComplexValidatorStreaming.swift | 2 +- Sources/Schema/XSDParserParticles.swift | 14 ++--- Tests/SchemaAllGroupMinOccursTests.swift | 60 +++++++++++++++++++ Tests/XSTSSuiteTests.swift | 7 ++- 7 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 Tests/SchemaAllGroupMinOccursTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b600e0a..2f543c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An `xs:all` group with `minOccurs="0"` now requires its members once the group is present in the instance. The group's `minOccurs="0"` makes the GROUP optional (it occurs zero or one times), not each member optional: an absent group (no matching children) is valid, but a present group (any matching child) must still satisfy every member's own `minOccurs`. The parser previously forced each member's `minOccurs` to 0 when the all-group was optional, so a non-empty but incomplete optional all-group (a required member missing) was wrongly accepted. Members now keep their declared occurrence, and the absent-group case is handled at validation time by skipping the required-member check only when the all-group contributes no children at all; a required all-group (`minOccurs="1"`) is unchanged. The fix is applied identically on the tree and streaming paths (both call `allStructureErrors`). Because members now keep their true `minOccurs`, the particle-restriction check (cos-group-restrict) also sees them correctly: a complexContent restriction that relaxes a required member of an optional all-group base is now rejected too, so this catches mgZ001 (instance) and particlesK006 (schema). XSTS invalid-instances-accepted 21 → 20 and invalid-schemas-accepted 18 → 17, valid-instances-rejected and valid-schemas-rejected both held at 0. - A nilled element (an `xsi:nil="true"` on a nillable declaration) whose declaration carries a fixed `{value constraint}` is now rejected during instance validation (cvc-elt.3.2.2). A nillable element MAY declare a fixed value (the schema is valid); the conflict arises only when an instance actually nils such an element, since a nilled element has no value and so cannot take its fixed one. The check rides the existing `nilErrors` chokepoint (shared by the tree and streaming validators), using the matched particle's constraint with the by-local-name element-constraint map as a fallback, so it is enforced identically on both paths; a nillable element that supplies its fixed value, and a nilled element with no fixed value constraint, are both unaffected. Catches addB065. XSTS invalid-instances-accepted 22 → 21, valid-instances-rejected held at 0. - A top-level (global) element or attribute declaration carrying a `form` attribute is now rejected even when it lives in an INCLUDED or IMPORTED document. The `form` attribute is permitted only on a local declaration (XSD 1.0 schema-for-schemas: `topLevelAttribute`/`topLevelElement` have no `form`, since a global component's qualification is fixed); the check (`topLevelDeclarationFindings`, which also enforces a global needing a `name` and forbids the other local-only `use`/`ref`/`minOccurs`/`maxOccurs`) already existed but ran only over the document being compiled, so a violation in an `xs:include`/`xs:import`/`xs:redefine` target was missed and the composed schema wrongly accepted. The same global-component check now runs over the other schema-document roots in the composition (the main document is excluded as already covered, and `redefine` entries are skipped because each schema root walks its own `redefine` children). The constraint is absolute and document-position-independent, so no valid schema is newly rejected; libxml2 rejects both cases for the same reason. Catches attQ016 (import) and attQ018 (include). XSTS invalid-schemas-accepted 20 → 18, valid-schemas-rejected held at 0. - An identity-constraint field whose DECLARED target is a complex type with non-simple content is now rejected even when the instance node has no element children. The earlier fix rejected a field whose selected node carries element children (structural); this adds the case where the field's declared type is an empty or element-only/mixed complex type (an attributes-only element resolves to an empty-content complex type, so its instance node is childless yet not simple-typed). The field's target type is resolved through the schema type model (the selector target's type, then the field's same-named child element); a simple or simpleContent target, a type reached only through a named reference, a complex field path, or an unresolvable target is left alone, so no valid field is rejected. Catches idG006. XSTS invalid-instances-accepted 23 → 22, valid-instances-rejected held at 0. diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 76ad435..c4b05a8 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -208,7 +208,7 @@ public extension PureXML.Schema { // particle, actually matched. let matched: [PureXML.Schema.MatchedParticle?] if case let .group(group) = particle.term, group.compositor == .all { - allStructureErrors(group, children: children, at: path, into: &errors) + allStructureErrors(group, children: children, groupOptional: particle.occurrenceRange.minimum.isZero, at: path, into: &errors) matched = allMatchedParticles(group, children: children) } else { sequenceStructureErrors(particle, children: children, at: path, into: &errors) diff --git a/Sources/Schema/ComplexValidatorContentModel.swift b/Sources/Schema/ComplexValidatorContentModel.swift index 3ee5049..ab3f3ef 100644 --- a/Sources/Schema/ComplexValidatorContentModel.swift +++ b/Sources/Schema/ComplexValidatorContentModel.swift @@ -26,7 +26,7 @@ extension PureXML.Schema.ComplexValidator { /// Locates `all`-group violations: each child that is not an in-bounds member, /// recovering past it, then each required member that never appeared. - func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) { + func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], groupOptional: Bool = false, at path: XSDPath, into errors: inout [XSDFailure]) { var counts = [Int](repeating: 0, count: group.particles.count) let steps = Self.childSteps(children) for (index, child) in children.enumerated() { @@ -40,6 +40,11 @@ extension PureXML.Schema.ComplexValidator { } counts[position] += 1 } + // An optional `all` group (`minOccurs="0"`) that contributes no children is + // absent (zero occurrences), so its required members are not expected; but + // once any child appears the group is present once and every member's own + // `minOccurs` applies. A required group (`minOccurs="1"`) always enforces. + if groupOptional, children.isEmpty { return } for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs { if case let .element(name, _, _, _, _, _) = member.term { errors.append(XSDFailure(reason: "element '\(name.localName)' is required but missing", at: path)) diff --git a/Sources/Schema/ComplexValidatorStreaming.swift b/Sources/Schema/ComplexValidatorStreaming.swift index 08e83ec..12efdc6 100644 --- a/Sources/Schema/ComplexValidatorStreaming.swift +++ b/Sources/Schema/ComplexValidatorStreaming.swift @@ -238,7 +238,7 @@ public extension PureXML.Schema.ComplexValidator { private func shallowStructure(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) { if case let .group(group) = particle.term, group.compositor == .all { - allStructureErrors(group, children: children, at: path, into: &errors) + allStructureErrors(group, children: children, groupOptional: particle.occurrenceRange.minimum.isZero, at: path, into: &errors) } else { sequenceStructureErrors(particle, children: children, at: path, into: &errors) } diff --git a/Sources/Schema/XSDParserParticles.swift b/Sources/Schema/XSDParserParticles.swift index a2774f1..ff56a3e 100644 --- a/Sources/Schema/XSDParserParticles.swift +++ b/Sources/Schema/XSDParserParticles.swift @@ -30,14 +30,12 @@ extension PureXML.Schema.XSDParser { if let member = particle(child, context) { particles.append(member) } } let occurrence = XSDNode.occurrenceRange(node) - if compositor == .all, occurrence.minimum.isZero { - particles = particles.map { - Particle( - occurrenceRange: .init(minimum: .init(0), maximum: $0.occurrenceRange.maximum), - term: $0.term, - ) - } - } + // An `all` group's own `minOccurs="0"` makes the GROUP optional (it occurs + // zero or one times), not its members optional: when the group is present + // (any child appears) every member keeps its own `minOccurs`. The members + // therefore retain their declared occurrence here; the absent-group case is + // handled at validation time (see allStructureErrors) by skipping the + // required-member check only when the group has no children at all. return Particle( occurrenceRange: occurrence, term: .group(Group(compositor: compositor, particles: particles)), diff --git a/Tests/SchemaAllGroupMinOccursTests.swift b/Tests/SchemaAllGroupMinOccursTests.swift new file mode 100644 index 0000000..0af0c5d --- /dev/null +++ b/Tests/SchemaAllGroupMinOccursTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import PureXML + +/// An `xs:all` group's own `minOccurs="0"` makes the GROUP optional (it occurs +/// zero or one times), not its members optional. An absent group (no children) +/// is valid; a present group (any child) must still satisfy each member's own +/// `minOccurs`. Earlier the parser forced every member's `minOccurs` to 0, so a +/// non-empty but incomplete optional all-group was wrongly accepted. Mirrors XSTS +/// mgZ001. +@Suite("optional xs:all group member requirements") +struct SchemaAllGroupMinOccursTests { + private func schema() throws -> PureXML.Schema.Document { + try PureXML.Schema.Document(""" + + + + + + + + + + + + + + + + + + + + """) + } + + @Test("an absent optional all-group is valid") + func test_optionalAbsent() throws { + #expect(try schema().validate("").isEmpty) + } + + @Test("a present optional all-group missing a required member is rejected") + func test_optionalPresentIncomplete() throws { + // 'a' is required once the group is present (c alone is not enough). + #expect(try !schema().validate("").isEmpty) + // 'c' is required too. + #expect(try !schema().validate("").isEmpty) + } + + @Test("a complete optional all-group is valid") + func test_optionalPresentComplete() throws { + #expect(try schema().validate("").isEmpty) + #expect(try schema().validate("").isEmpty) + } + + @Test("a required all-group still enforces its members when empty") + func test_requiredEmptyRejected() throws { + #expect(try !schema().validate("").isEmpty) // 'a' required + #expect(try schema().validate("").isEmpty) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 22791c1..1938376 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -31,7 +31,7 @@ struct XSTSSuiteTests { /// history of how each bucket reached its current value (every rule and its exact /// delta) lives in CHANGELOG.md and docs/xsts-deviations.md, not inline here. private let knownSchemaValidRejected = 0 - private let knownSchemaInvalidAccepted = 18 + private let knownSchemaInvalidAccepted = 17 private let knownInstanceValidRejected = 0 /// Instance bucket (133 -> 22), per-step deltas in CHANGELOG.md: xsi:type must derive from the declared /// type; anyType cannot stand in for anySimpleType; an untyped substitutionGroup member inherits its head's @@ -39,8 +39,9 @@ struct XSTSSuiteTests { /// typed member reached by a blocked method, disallowedSubst00105m); ur-type anyType wildcards are `lax`; /// identity fields compare in decimal value space (default/fixed when absent) and must be simple-content, /// element-children (idK012) or empty-complex (idG006); an attr wildcard is the INTERSECTION; year 0000 invalid; - /// a nilled element may not have a fixed value constraint, cvc-elt.3.2.2 (addB065). - private let knownInstanceInvalidAccepted = 21 + /// a nilled element may not have a fixed value constraint, cvc-elt.3.2.2 (addB065); + /// an optional xs:all group present in the instance still requires its members (mgZ001). + private let knownInstanceInvalidAccepted = 20 /// Suspect instance tests excluded from the counts: the Ethiopic-digit `\d` /// cases contradict `\d` = `\p{Nd}` at Unicode 3.2 and are tracked as disputed. private static let specDivergentInstances: Set = ["reS17.v", "reS38.v", "reZ004v.v"]