fix(schema): reject adding an attribute in an attribute-group redefine

A redefinition of an attribute group may not ADD an attribute the original does
not declare (a superset is not a restriction, src-redefine / cos-ct-restricts);
schM3 (redeclaring the original's attributes plus a new b4) is now rejected. The
base is resolved by namespaced identity with a {}name chameleon fallback for a
no-targetNamespace redefined schema.

False-positive-free: the added-attribute check runs only when the original's
attribute set is fully known from its direct declarations and the redefinition
carries no attributeGroup reference (a self-reference brings in the original's
attributes, so declaring more alongside it is a valid redefine, not an addition,
W3C attgC007/attgC038/groupB018, test75083/xsd003b), and a use=prohibited
attribute the base lacks forbids rather than permits a name, so it does not widen
the usable set and is left alone.

XSTS invalid-schemas-accepted 51 -> 49, no false positive (valid-schemas-rejected
and valid-instances-rejected held at 0; five self-reference over-rejections were
caught by the gate and one prohibited-attribute over-rejection by an adversarial
critic, all fixed before commit), no other bucket moved.
This commit is contained in:
Mihaela Mihaljevic 2026-06-18 18:41:13 +02:00
parent af5d7882b0
commit afad5f822c
4 changed files with 65 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 redefinition of an attribute group may not ADD an attribute the original does not declare (a superset is not a restriction, src-redefine / cos-ct-restricts); schM3 (redeclaring the original's attributes plus a new `b4`) is now rejected. The base is resolved by namespaced identity with a `{}name` chameleon fallback (a no-targetNamespace redefined schema is absorbed into the redefining namespace). To stay false-positive-free the added-attribute check runs only when the original's attribute set is fully known from its direct declarations (the original has no nested `attributeGroup` reference) AND the redefinition itself carries no `attributeGroup` reference: a self-reference brings in the original's attributes, so declaring further ones alongside it is a valid redefine (W3C attgC007/attgC038/groupB018, and test75083/xsd003b), not an addition. invalid-schemas-accepted 51 → 49, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; five self-reference over-rejections surfaced by the gate were fixed before commit), no other bucket moved.
- 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.

View File

@ -71,7 +71,12 @@ extension PureXML.Schema.XSDParser {
for container in containers where RedefineNode.localName(container) == "redefine" {
for redefinition in RedefineNode.children(container, named: "attributeGroup") {
guard let name = RedefineNode.attribute(redefinition, "name"),
let baseGroup = base[namespacedKey(name, owner: redefinition)]
// The redefined schema's components are in the redefining schema's
// own namespace, or in none when it is a chameleon (no
// targetNamespace); try both. A base with a different explicit
// namespace keys under neither, so no cross-namespace group is
// mistaken for the original.
let baseGroup = base[namespacedKey(name, owner: redefinition)] ?? base["{}\(name)"]
else { continue }
errors += attributeGroupRestrictionErrors(name: name, base: baseGroup, redefinition: redefinition)
}
@ -100,6 +105,24 @@ extension PureXML.Schema.XSDParser {
let use: (String) -> String? = { redefined[$0].map { RedefineNode.attribute($0, "use") ?? "optional" } }
let hasReference = children.contains { RedefineNode.localName($0) == "attributeGroup" }
let baseHasWildcard = RedefineNode.elementChildren(base).contains { RedefineNode.localName($0) == "anyAttribute" }
// A restriction may not ADD an attribute the original does not have (a superset).
// Checked only when the original's attribute set is fully known from its direct
// declarations (no nested attributeGroup reference could supply the name), and
// only when the redefinition itself carries no attributeGroup reference: a
// self-reference brings in the original's attributes, so declaring further ones
// alongside it is a valid redefine (W3C attgC007/attgC038/groupB018), not an
// addition. Both guards keep the check from mistaking an inherited attribute for
// a newly added one.
let baseNames = Set(RedefineNode.children(base, named: "attribute").compactMap { RedefineNode.attribute($0, "name") })
let baseHasReference = RedefineNode.elementChildren(base).contains { RedefineNode.localName($0) == "attributeGroup" }
if !baseHasReference, !hasReference {
// A `use="prohibited"` attribute forbids the name rather than permitting it,
// so declaring one the base lacks does not widen the usable set; like the
// re-introduce and fixed-value checks, it is left alone.
for attrName in redefined.keys.sorted() where !baseNames.contains(attrName) && use(attrName) != "prohibited" {
errors.append("a redefined attribute group '\(name)' may not add the attribute '\(attrName)'")
}
}
for attribute in RedefineNode.children(base, named: "attribute") {
guard let attrName = RedefineNode.attribute(attribute, "name") else { continue }
// A restriction may not relax a fixed value: if the original fixes an

View File

@ -61,4 +61,42 @@ struct SchemaRedefineAttributeGroupTests {
base: "<xs:attribute name='a' type='xs:string' fixed='abc'/>",
)
}
@Test("adding an attribute not in the original is rejected (superset)")
func test_addAttributeRejected() throws {
#expect(throws: (any Error).self) {
try redefine(
group: "<xs:attribute name='a' type='xs:string'/><xs:attribute name='b' type='xs:string'/>",
base: "<xs:attribute name='a' type='xs:string'/>",
)
}
}
@Test("declaring a prohibited attribute the base lacks is valid (not a superset)")
func test_addProhibitedAccepted() throws {
_ = try redefine(
group: "<xs:attribute name='a' type='xs:string'/><xs:attribute name='z' use='prohibited' type='xs:string'/>",
base: "<xs:attribute name='a' type='xs:string'/>",
)
}
@Test("adding an attribute alongside a self-reference is valid")
func test_addWithSelfReferenceAccepted() throws {
_ = try redefine(
group: "<xs:attributeGroup ref='g'/><xs:attribute name='b' type='xs:string'/>",
base: "<xs:attribute name='a' type='xs:string'/>",
)
}
@Test("re-declaring an attribute the base inherits via a reference is valid")
func test_redeclareInheritedAccepted() throws {
let main = "<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>"
+ "<xs:redefine schemaLocation='b.xsd'><xs:attributeGroup name='g'>"
+ "<xs:attribute name='x' type='xs:string'/></xs:attributeGroup></xs:redefine>"
+ "<xs:element name='e'/></xs:schema>"
let baseDoc = "<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>"
+ "<xs:attributeGroup name='inner'><xs:attribute name='x' type='xs:string'/></xs:attributeGroup>"
+ "<xs:attributeGroup name='g'><xs:attributeGroup ref='inner'/></xs:attributeGroup></xs:schema>"
_ = try PureXML.Schema.Document(main, schemaLoader: { _ in baseDoc })
}
}

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 = 51
private let knownSchemaInvalidAccepted = 49
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);