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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-18 18:24:58 +02:00
parent 1d17d2089f
commit af5d7882b0
7 changed files with 171 additions and 2 deletions

View File

@ -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.)

View File

@ -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),

View File

@ -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<PureXML.Schema.SchemaCompileRoot, PureXML.Schema.SchemaCompileContext> {
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

View File

@ -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 `<group ref>` 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 (`<group ref>` 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

View File

@ -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 = "<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>"
+ "<xs:redefine schemaLocation='b.xsd'><xs:group name='g'>\(body)</xs:group></xs:redefine>"
+ "<xs:complexType name='t'><xs:group ref='g'/></xs:complexType>"
+ "<xs:element name='e' type='t'/></xs:schema>"
let baseDoc = "<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>"
+ "<xs:group name='g'>\(base)</xs:group>"
+ "<xs:complexType name='b'><xs:group ref='g'/></xs:complexType></xs:schema>"
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: "<xs:sequence><xs:element name='a' type='xs:string'/></xs:sequence>",
base: "<xs:sequence/>",
)
}
}
@Test("dropping a required member from an all group is rejected")
func test_dropRequiredFromAllRejected() throws {
#expect(throws: (any Error).self) {
try redefine(
group: "<xs:all><xs:element name='a' type='xs:string'/></xs:all>",
base: "<xs:all><xs:element name='a' type='xs:string'/><xs:element name='b' type='xs:string'/></xs:all>",
)
}
}
@Test("reordering an all group is rejected (order-preserving Recurse)")
func test_reorderAllRejected() throws {
#expect(throws: (any Error).self) {
try redefine(
group: "<xs:all><xs:element name='b' type='xs:string'/><xs:element name='a' type='xs:string'/></xs:all>",
base: "<xs:all><xs:element name='a' type='xs:string'/><xs:element name='b' type='xs:string'/></xs:all>",
)
}
}
@Test("dropping an optional member is a valid restriction")
func test_dropOptionalAccepted() throws {
_ = try redefine(
group: "<xs:sequence><xs:element name='a' type='xs:string'/></xs:sequence>",
base: "<xs:sequence><xs:element name='a' type='xs:string'/><xs:element name='b' type='xs:string' minOccurs='0'/></xs:sequence>",
)
}
@Test("re-enabling a maxOccurs=0 base member is left unjudged and accepted")
func test_pointlessParticleBaseAccepted() throws {
_ = try redefine(
group: "<xs:all><xs:element name='a' type='xs:string'/></xs:all>",
base: "<xs:all><xs:element name='a' type='xs:string' minOccurs='0' maxOccurs='0'/></xs:all>",
)
}
@Test("an unchanged group redefinition is a valid restriction")
func test_identityAccepted() throws {
_ = try redefine(
group: "<xs:sequence><xs:element name='a' type='xs:string'/></xs:sequence>",
base: "<xs:sequence><xs:element name='a' type='xs:string'/></xs:sequence>",
)
}
}

View File

@ -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",

View File

@ -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);