From af5d7882b0b0255e2e591e0717d0efc5067cd978 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 18 Jun 2026 18:24:58 +0200 Subject: [PATCH] fix(schema): enforce model-group redefine restriction (cos-group-restrict) A non-self-referencing model group redefined inside xs:redefine must be a valid restriction of the group it redefines (cos-group-restrict / src-redefine 6.1.2), so its content model may not widen the original's language. The check reuses the particle-restriction oracle used for complex-type restriction, resolves the original group by namespaced identity with a {}name chameleon fallback, and runs post-compile where element type compatibility is available. Now rejected: adding required content to an empty base (schZ006), dropping a required all member (schL1), reordering an all (schL5/L6, the Recurse rule is order-preserving even for all), growing to a superset (schL8), and the multi-document/chained redefines schN4/5/6/7 and schL10. To stay false-positive-free the check skips a redefinition carrying a self-reference and declines to judge a base with a maxOccurs=0 pointless particle (the contested case W3C accepts, mgO013); genuine restrictions (dropping an optional member, an unchanged redefinition) are accepted. XSTS invalid-schemas-accepted 64 -> 51, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; an over-rejection of mgO013 surfaced by the gate was fixed before commit, and an adversarial critic confirmed FP-safety), no other bucket moved. Disclosed under-rejections: self-referencing redefinitions (schN10) and maxOccurs=0-base redefines. --- CHANGELOG.md | 2 + Sources/Schema/SchemaCompileValidation.swift | 2 +- .../Schema/SchemaCompositionValidations.swift | 18 +++++ .../Schema/SchemaRedefineSelfReference.swift | 71 +++++++++++++++++ .../SchemaGroupRedefineRestrictionTests.swift | 77 +++++++++++++++++++ Tests/ValidationConfigurationTests.swift | 1 + Tests/XSTSSuiteTests.swift | 2 +- 7 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 Tests/SchemaGroupRedefineRestrictionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc505f..c75e6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- XSTS invalid-schemas-accepted (#145, #161): a non-self-referencing model group redefined inside `xs:redefine` must be a valid RESTRICTION of the group it redefines (`cos-group-restrict` / `src-redefine` 6.1.2), so its content model may not widen the original's language. The check reuses the same particle-restriction oracle as complex-type restriction, resolving the original group by namespaced identity (with a chameleon `{}name` fallback for a no-targetNamespace redefined schema). Cases now rejected include adding required content to an empty base (schZ006), dropping a required `all` member so documents without it become valid (schL1), reordering an `all` (schL5/L6, since the spec's Recurse rule is order-preserving even for `all`), and growing a group to a superset (schL8), plus the multi-document and chained redefines schN4/5/6/7 and schL10. To stay false-positive-free the check skips a redefinition carrying a self-reference (governed by the self-reference well-formedness rule and not re-parseable here) and declines to judge a base containing a `maxOccurs=0` "pointless particle" (the contested normalization case where W3C accepts a redefinition re-enabling that member, mgO013); a genuine restriction (dropping an optional member, an unchanged redefinition) is accepted. invalid-schemas-accepted 64 → 51, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; an over-rejection of mgO013 surfaced by the gate was fixed before commit), no other bucket moved. Disclosed under-rejections: self-referencing redefinitions (schN10) and `maxOccurs=0`-base redefines. + - XSTS invalid-schemas-accepted (#145, #161): a redefinition of an attribute group restricts the original, so it may neither eliminate a required attribute the original declares, nor re-introduce a prohibited one as usable, nor relax a fixed value the original sets (src-redefine / cos-ct-restricts). attgZ002 (dropping the required `att1`), attgZ003 (re-adding the prohibited `att1` as optional), and schM10 (changing a `fixed` attribute to a `default` one) are now rejected. Re-declaring a prohibited attribute AS prohibited keeps it prohibited and stays valid, the drop-required check is skipped when the redefinition uses a nested attributeGroup reference (which could re-introduce the attribute), and the fixed-value check fires only when the redefinition declares the matching attribute as non-prohibited with a differing fixed value (keeping the same fixed value, prohibiting it, or omitting it is left alone), so no valid redefinition is rejected. invalid-schemas-accepted 67 → 64, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved. - A complex type whose `simpleContent` derives from a list or union simple type now keeps that variety, so its element content is validated as a list or union rather than collapsing to an atomic value space. Previously `simpleContentType` dropped the base's variety, so an invalid item in a list-typed simpleContent (e.g. a non-int in a list-of-int) was accepted. A valid list/union value is unaffected. (Under-rejection fix surfaced by the libxml2 differential; the W3C suite does not exercise it, so the conformance counts are unchanged.) diff --git a/Sources/Schema/SchemaCompileValidation.swift b/Sources/Schema/SchemaCompileValidation.swift index 85e3357..ecde1f4 100644 --- a/Sources/Schema/SchemaCompileValidation.swift +++ b/Sources/Schema/SchemaCompileValidation.swift @@ -267,7 +267,7 @@ extension PureXML.Validation { AnyValidation(userTypeValueConstraintsValid), AnyValidation(extensionAllGroupsValid), AnyValidation(anonymousRestrictionsValid), - AnyValidation(complexExtensionBaseValid), + AnyValidation(complexExtensionBaseValid), AnyValidation(redefinedGroupsRestrictBase), AnyValidation(attributeRestrictionsFaithful), AnyValidation(simpleTypeBasesAreSimple), AnyValidation(simpleTypeVarietiesValid), diff --git a/Sources/Schema/SchemaCompositionValidations.swift b/Sources/Schema/SchemaCompositionValidations.swift index eadecfd..aaf5aa4 100644 --- a/Sources/Schema/SchemaCompositionValidations.swift +++ b/Sources/Schema/SchemaCompositionValidations.swift @@ -45,6 +45,24 @@ extension PureXML.Validation.SchemaCompile { } } + /// cos-group-restrict: a model group redefined through `xs:redefine` restricts the + /// original, so its content model must accept a subset of the original's. Reuses + /// the particle-restriction oracle; runs after named types resolve so element type + /// compatibility is available. + static var redefinedGroupsRestrictBase: PureXML.Validation.Validation { + compileRule("A redefined model group restricts the group it redefines") { document in + guard let namedTypes = document.namedTypes, let derivation = document.derivation else { return [] } + return PureXML.Schema.SchemaLocatedFinding.unlocated( + PureXML.Schema.XSDParser.redefineGroupRestrictionErrors( + document.containers, + context: document.context, + types: namedTypes, + derivation: derivation.typeDerivation, + ), + ) + } + } + /// Facet definition validity and pattern syntax validity (XSD Part 2 4.3): a /// constraining facet must be applicable to and valid for its base type, and a /// `pattern` must compile. These are gathered during simple-type compilation diff --git a/Sources/Schema/SchemaRedefineSelfReference.swift b/Sources/Schema/SchemaRedefineSelfReference.swift index cfa0871..772e0f9 100644 --- a/Sources/Schema/SchemaRedefineSelfReference.swift +++ b/Sources/Schema/SchemaRedefineSelfReference.swift @@ -128,6 +128,77 @@ extension PureXML.Schema.XSDParser { return RedefineNode.attribute(redefined, "fixed") != fixed } + /// cos-group-restrict: a model group redefined inside `xs:redefine` is a + /// RESTRICTION of the group it redefines, so its content model must accept a + /// subset of the original's. The check reuses the same particle-restriction + /// oracle as complex-type restriction (`ParticleRestriction.violation`). To stay + /// false-positive-free it runs only when the original group resolves by namespaced + /// identity and when the redefinition has NO self-reference (a `` to its + /// own name expands to the original and is governed by the self-reference + /// well-formedness rule instead, and cannot be re-parsed reliably here). An + /// order-independent `all` reordering accepts the same language, so the oracle + /// correctly does not flag it; only a genuine content widening is reported. + static func redefineGroupRestrictionErrors( + _ containers: [XSDTree], + context: PureXML.Schema.XSDContext, + types: [String: PureXML.Schema.ElementType], + derivation: [String: PureXML.Schema.TypeDerivation], + ) -> [String] { + var base: [String: XSDTree] = [:] + for container in containers where RedefineNode.localName(container) != "redefine" { + for group in RedefineNode.children(container, named: "group") { + if let name = RedefineNode.attribute(group, "name") { base[namespacedKey(name, owner: group)] = group } + } + } + var errors: [String] = [] + for container in containers where RedefineNode.localName(container) == "redefine" { + for redefinition in RedefineNode.children(container, named: "group") { + guard let name = RedefineNode.attribute(redefinition, "name"), + !hasGroupSelfReference(redefinition, name: name), + // The redefined schema's components live in the redefining schema's + // own namespace, or in none when it is a chameleon (no + // targetNamespace) absorbed on redefine; try both keys. A base with + // a DIFFERENT explicit namespace keys under neither, so no + // cross-namespace component is mistaken for the original. + let original = base[namespacedKey(name, owner: redefinition)] ?? base["{}\(name)"], + let redefined = modelGroup(in: redefinition, context), + let baseline = modelGroup(in: original, context), + // A `maxOccurs=0` ("pointless") member in the original puts the + // redefine into contested cos-group-restrict territory (the spec's + // normalization removes it, yet XSTS accepts a redefinition that + // re-enables that very member, e.g. mgO013). Decline to judge such + // a base rather than risk over-rejecting a valid redefine; this + // only withholds a rejection, so it cannot introduce a false positive. + !hasPointlessParticle(baseline) + else { continue } + if let reason = PureXML.Schema.ParticleRestriction.violation( + restricted: .elementOnly(redefined), base: .elementOnly(baseline), types: types, derivation: derivation, + ) { + errors.append("a redefined model group '\(name)' must restrict the group it redefines: \(reason)") + } + } + } + return errors + } + + /// Whether `particle` or any of its descendants can never occur (`maxOccurs=0`), a + /// "pointless particle" whose normalization in the redefine-restriction check is + /// contested. + private static func hasPointlessParticle(_ particle: PureXML.Schema.Particle) -> Bool { + if particle.maxOccurs == 0 { return true } + if case let .group(group) = particle.term { return group.particles.contains(where: hasPointlessParticle) } + return false + } + + /// Whether a redefinition group contains a self-reference (`` to its own + /// name within its bounded model), which expands to the original and is handled by + /// the self-reference well-formedness rule rather than re-parsed here. + private static func hasGroupSelfReference(_ redefinition: XSDTree, name: String) -> Bool { + boundedSelfReferenceNodes(redefinition, "group").contains { ref in + RedefineNode.attribute(ref, "ref").map(RedefineNode.stripPrefix) == name + } + } + /// The reference NODES of `kind` nested directly in `node`'s model, stopping at /// `element`/`complexType`/`simpleType`/`attribute` scopes so a reference inside a /// nested element's content (a data-structure recursion, not a redefinition diff --git a/Tests/SchemaGroupRedefineRestrictionTests.swift b/Tests/SchemaGroupRedefineRestrictionTests.swift new file mode 100644 index 0000000..143496a --- /dev/null +++ b/Tests/SchemaGroupRedefineRestrictionTests.swift @@ -0,0 +1,77 @@ +@testable import PureXML +import Testing + +/// A non-self-referencing model group redefined inside `xs:redefine` must be a valid +/// restriction of the group it redefines (XSD 1.0 cos-group-restrict / src-redefine +/// 6.1.2). Adding content the original cannot produce, or dropping a required member +/// so the language widens, is rejected; a genuine restriction is accepted. The +/// order-preserving Recurse rule applies even to `xs:all`, so reordering an `all` is +/// rejected (W3C schL5), while a base with a `maxOccurs=0` member is left unjudged to +/// avoid the contested pointless-particle case (W3C mgO013). +@Suite("redefined model group restriction") +struct SchemaGroupRedefineRestrictionTests { + private func redefine(group body: String, base: String) throws -> PureXML.Schema.Document { + let main = "" + + "\(body)" + + "" + + "" + let baseDoc = "" + + "\(base)" + + "" + return try PureXML.Schema.Document(main, schemaLoader: { _ in baseDoc }) + } + + @Test("adding required content to an empty base group is rejected") + func test_addRequiredToEmptyRejected() throws { + #expect(throws: (any Error).self) { + try redefine( + group: "", + base: "", + ) + } + } + + @Test("dropping a required member from an all group is rejected") + func test_dropRequiredFromAllRejected() throws { + #expect(throws: (any Error).self) { + try redefine( + group: "", + base: "", + ) + } + } + + @Test("reordering an all group is rejected (order-preserving Recurse)") + func test_reorderAllRejected() throws { + #expect(throws: (any Error).self) { + try redefine( + group: "", + base: "", + ) + } + } + + @Test("dropping an optional member is a valid restriction") + func test_dropOptionalAccepted() throws { + _ = try redefine( + group: "", + base: "", + ) + } + + @Test("re-enabling a maxOccurs=0 base member is left unjudged and accepted") + func test_pointlessParticleBaseAccepted() throws { + _ = try redefine( + group: "", + base: "", + ) + } + + @Test("an unchanged group redefinition is a valid restriction") + func test_identityAccepted() throws { + _ = try redefine( + group: "", + base: "", + ) + } +} diff --git a/Tests/ValidationConfigurationTests.swift b/Tests/ValidationConfigurationTests.swift index 8bfe014..37a1ac3 100644 --- a/Tests/ValidationConfigurationTests.swift +++ b/Tests/ValidationConfigurationTests.swift @@ -107,6 +107,7 @@ struct ValidationConfigurationTests { "Complex types extending xs:all groups satisfy XSD placement rules", "Anonymous complex-type restrictions accept a subset of their base's", "A content-model derivation is consistent with its base type", + "A redefined model group restricts the group it redefines", "Attribute restrictions are faithful to their bases", "Simple types do not derive from complex types", "Simple-type varieties are declared consistently", diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 4b8cb24..d223830 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -148,7 +148,7 @@ struct XSTSSuiteTests { /// and the cross-document composition family (src-resolve, src-import.3, /// src-redefine.5/6.1/7.2.1, redefine cross-namespace, required schemaLocation). private let knownSchemaValidRejected = 0 - private let knownSchemaInvalidAccepted = 64 + private let knownSchemaInvalidAccepted = 51 private let knownInstanceValidRejected = 0 /// Instance bucket (133 -> 31), per-step deltas in CHANGELOG.md: xsi:type must derive from the /// declared type (complex, then atomic and list/union, resolved through the element-ref chain);