fix(schema): reject redefined attribute group that widens a re-declared attribute's type

A redefinition of an attributeGroup is a restriction of the original
(src-redefine / cos-ct-restricts), so a re-declared attribute's type must derive
by restriction from the original's. The attribute-restriction checks covered
drop-required, re-introduce-prohibited, add-attribute, and relax-fixed, but not a
type change that widens rather than restricts.

Add a built-in-only, namespace-gated check: it fires only when both the original
and the re-declared attribute name a built-in type resolving to the XSD namespace
and the re-declared built-in does not derive from the original's in the XSD
derivation lattice (int does not derive from boolean). A user, inline, or absent
type on either side is not compared, so the check declines and stays lenient: an
absent original type is anySimpleType (which any type restricts), a user/inline
type's derivation is not resolvable here, and a user type whose local name
coincides with a built-in resolves to a different namespace and is not mistaken
for the built-in.

This catches W3C XSTS schM4 (a redefinition re-typing an attribute from boolean
to int). XSTS schema invalid-accepted 24 -> 23, valid-rejected held at 0.
This commit is contained in:
Mihaela Mihaljevic 2026-06-22 02:26:38 +02:00
parent 81805ad53e
commit b6e5cd8f3f
4 changed files with 83 additions and 2 deletions

View File

@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A redefinition of an `attributeGroup` that WIDENS a re-declared attribute's type is now rejected. A redefinition is a restriction (src-redefine / cos-ct-restricts), so a re-declared attribute's type must derive by restriction from the original's. The check is built-in-only and therefore certain: it fires only when both the original and the re-declared attribute name a built-in type (resolved to the XSD namespace) and the re-declared built-in does not derive from the original's in the XSD derivation lattice (`int` does not derive from `boolean`). A user, inline, or absent type on either side is not compared (declines, staying lenient): an absent original type is `anySimpleType`, which any type validly restricts, a user/inline type's derivation is not resolvable here, and a user type whose local name coincides with a built-in (a `{urn:x}int`) resolves to a different namespace and is not mistaken for the built-in, so no valid restriction is rejected. Catches schM4 (`b1` re-typed `boolean` → `int`). XSTS invalid-schemas-accepted 24 → 23 (schM4), valid-schemas-rejected held at 0.
- A redefinition of an `attributeGroup` that adds an attribute the original lacks through a NON-self `attributeGroup` reference is now rejected. A redefinition is a restriction of the original (src-redefine / cos-ct-restricts), so it may not introduce an attribute the original does not declare unless the original carries an attribute wildcard. The add-attribute check previously resolved only the redefinition's DIRECT declarations and was suppressed whenever the redefinition carried any `attributeGroup` reference, which conflated a self-reference (re-importing the original's own attributes, valid) with a non-self reference (injecting foreign attributes, invalid). The redefinition's contributed attribute names are now resolved through its non-self references and the suppression keys on a genuine self-reference (matched by namespace and local name); resolution declines, staying lenient, whenever the contributed set is not fully knowable (an unresolved group, an attribute-by-`ref`, an `anyAttribute` wildcard, or a further nested reference). This catches attgC028 (a redefinition whose `ref="car"` replaces the original's attributes with foreign ones) while keeping attgC007/attgC038/groupB018 (added attributes alongside a self-reference) valid. XSTS invalid-schemas-accepted 25 → 24 (attgC028), valid-schemas-rejected held at 0.
- An unprefixed `type`/`base`/`itemType`/`memberTypes` reference to a type declared in a non-empty target namespace is now rejected as undeclared. With no default `xmlns`, an unprefixed QName resolves to the absent namespace, so `type="t"` is `{}t`, not `{urn:x}t` where the type lives; `isUndeclaredReferenceType` matched the type by local name and accepted it. It now mirrors `isUndeclaredReferenceRef`: a no-namespace reference is undeclared when the schema has a non-empty target namespace (a chameleon-included global, and an imported no-namespace type via the foreign pools, remain accepted). XSTS invalid-schemas-accepted 27 → 25 (addB009/test66059 and one more), valid-schemas-rejected held at 0.
- A schema reference (`ref`/`substitutionGroup`) that resolves to the XSD namespace is now rejected as undeclared. The XSD namespace declares no referenceable user component, so `isUndeclaredReferenceType` already rejected a `type`/`base` in it, but `isUndeclaredReferenceRef` fell through to a lenient accept. This is reached when an unprefixed reference picks up a default `xmlns` of the XSD namespace, so it is not in the target namespace where the schema's own components live: e.g. `xmlns="http://www.w3.org/2001/XMLSchema" targetNamespace="foo"` with `<element ref="root"/>` resolves to `{XSD}root`, not `{foo}root`, and is undeclared. A schema that itself targets the XSD namespace, and a genuinely loaded XSD-namespace document, are unaffected. XSTS invalid-schemas-accepted 29 → 27 (xsd013/xsd014), valid-schemas-rejected held at 0.

View File

@ -194,6 +194,9 @@ extension PureXML.Schema.XSDParser {
if relaxesFixedValue(base: attribute, redefined: redefined[attrName], use: use(attrName)) {
errors.append("a redefined attribute group '\(name)' may not relax the fixed value of '\(attrName)'")
}
if use(attrName) != "prohibited", widensBuiltinType(base: attribute, redefined: redefined[attrName]) {
errors.append("a redefined attribute group '\(name)' may not widen the type of '\(attrName)'")
}
switch RedefineNode.attribute(attribute, "use") {
case "required" where !hasReference && redefined[attrName] == nil:
errors.append("a redefined attribute group '\(name)' may not eliminate the required attribute '\(attrName)'")
@ -230,6 +233,37 @@ extension PureXML.Schema.XSDParser {
.map { "a redefined attribute group '\(name)' may not add the attribute '\($0)'" }
}
/// Whether a redefined attribute WIDENS the original's type. A redefinition is a
/// restriction, so a re-declared attribute's type must derive by restriction from
/// the original's. The check is BUILT-IN-only and therefore certain: it fires only
/// when both the original and the re-declared attribute name a built-in type and the
/// re-declared built-in does not derive from the original's in the XSD derivation
/// lattice (e.g. `int` does not derive from `boolean`, XSTS schM4). A user, inline,
/// or absent type on either side is not compared (returns false, staying lenient):
/// an absent original type is `anySimpleType`, which any type validly restricts, and
/// a user/inline type's derivation is not resolvable here, so no valid restriction
/// is rejected.
private static func widensBuiltinType(base: XSDTree, redefined: XSDTree?) -> Bool {
guard let redefined,
let baseBuiltin = builtinType(of: base),
let redefinedBuiltin = builtinType(of: redefined)
else { return false }
return !redefinedBuiltin.derives(from: baseBuiltin)
}
/// The built-in type an attribute names, ONLY when its `type` QName resolves to the
/// XSD namespace. A user type whose local name coincides with a built-in (a
/// `{urn:x}int`) resolves to a different namespace and returns nil, so it is not
/// mistaken for the built-in and `widensBuiltinType` declines rather than reject a
/// valid restriction. The QName is resolved against the owning schema's bindings,
/// the same context `redefineSelfReferenceFindings` uses for redefine references.
private static func builtinType(of attribute: XSDTree) -> PureXML.Schema.BuiltinType? {
guard let type = RedefineNode.attribute(attribute, "type") else { return nil }
let bindings = RedefineNode.namespaceBindings(of: RedefineNode.schemaOwner(attribute))
guard RedefineNode.referenceNamespace(type, bindings) == xsdNamespace else { return nil }
return PureXML.Schema.BuiltinType(rawValue: RedefineNode.stripPrefix(type))
}
/// A base attribute with a `fixed` value is relaxed when the redefinition declares
/// a matching, non-prohibited attribute whose `fixed` value differs (or is absent).
/// Prohibiting or omitting the attribute is not relaxation, so it is left alone.

View File

@ -88,6 +88,52 @@ struct SchemaRedefineAttributeGroupTests {
)
}
@Test("widening a re-declared attribute's built-in type is rejected (schM4)")
func test_widenBuiltinTypeRejected() throws {
// int does not derive by restriction from boolean, so re-typing the attribute
// from boolean to int widens it: a redefinition must restrict, not widen.
#expect(throws: (any Error).self) {
try redefine(
group: "<xs:attribute name='a' type='xs:int'/>",
base: "<xs:attribute name='a' type='xs:boolean'/>",
)
}
}
@Test("narrowing a re-declared attribute's built-in type within the lattice is valid")
func test_narrowBuiltinTypeAccepted() throws {
// int derives from integer, so re-typing integer -> int is a valid restriction.
_ = try redefine(
group: "<xs:attribute name='a' type='xs:int'/>",
base: "<xs:attribute name='a' type='xs:integer'/>",
)
}
@Test("re-typing an originally untyped attribute is valid (anySimpleType base)")
func test_retypeUntypedBaseAccepted() throws {
// An absent base type is anySimpleType, which any type validly restricts.
_ = try redefine(
group: "<xs:attribute name='a' type='xs:int'/>",
base: "<xs:attribute name='a'/>",
)
}
@Test("a user type whose local name coincides with a built-in is not mistaken for it (no false positive)")
func test_userTypeNamedLikeBuiltinNotWidened() throws {
// The redefinition re-types `a` to `tns:int`, a USER simpleType that restricts
// xs:string. Its local name is the built-in keyword `int`, but it resolves to
// the target namespace, not the XSD namespace, so the type-widening check must
// not treat it as xsd:int (which would not derive from xsd:string) and reject.
let main = "<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema' xmlns:tns='urn:t' targetNamespace='urn:t'>"
+ "<xs:redefine schemaLocation='b.xsd'><xs:attributeGroup name='g'>"
+ "<xs:attribute name='a' type='tns:int'/></xs:attributeGroup></xs:redefine>"
+ "<xs:simpleType name='int'><xs:restriction base='xs:string'/></xs:simpleType>"
+ "<xs:element name='e'/></xs:schema>"
let baseDoc = "<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema' targetNamespace='urn:t'>"
+ "<xs:attributeGroup name='g'><xs:attribute name='a' type='xs:string'/></xs:attributeGroup></xs:schema>"
_ = try PureXML.Schema.Document(main, schemaLoader: { _ in baseDoc })
}
@Test("a non-self reference injecting a foreign attribute is rejected (attgC028)")
func test_nonSelfReferenceAddRejected() throws {
let main = "<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>"

View File

@ -146,9 +146,9 @@ struct XSTSSuiteTests {
/// model-group occurrence, identity-constraint content, derivation base kind, attribute/simple-type,
/// composition, simpleContent inline-type, complexContent mixedness, and substitution-group anySimpleType checks.
/// src-resolve (#185) 43->39; xsi:Not-Allowed (#183) 39->37; NameAndTypeOK restriction-only + cos-all-limited group-ref (#158) 37->35.
/// UPA `##other` 35->31; UPA ext-model 31->29; ref-to-XSD-ns 29->27; unprefixed type-ref 27->25; redefine attrGroup non-self-ref add (attgC028) 25->24; valid-rejected 0.
/// UPA `##other` 31; ext-model 29; XSD-ns-ref 27; unprefixed-type-ref 25; redefine non-self-add attgC028 24; redefine type-widen schM4 23; valid-rejected 0.
private let knownSchemaValidRejected = 0
private let knownSchemaInvalidAccepted = 24
private let knownSchemaInvalidAccepted = 23
private let knownInstanceValidRejected = 0
/// Instance bucket (133 -> 26), per-step deltas in CHANGELOG.md: xsi:type must derive from the
/// declared type (complex/atomic/list/union via the element-ref chain); anyType cannot stand in