fix(schema): reject a list/union xsi:type on a non-ur-typed element; invalid-instances-accepted 81 to 78 (#181, #147)

A list or union simple type is derived only from xsd:anySimpleType (XSD Part 2),
so an xsi:type naming a list or union can validly stand in (cvc-elt.4.3.2.1) only
for an element whose declared type is a ur-type (anySimpleType/anyType). Naming
one on an element of a more specific atomic or complex type, such as
xsi:type="myList" on an xs:int element, is not a valid derivation and is now
rejected.

listUnionSubstitutionError flags the case; declaredAdmitsAnySimpleType suppresses
it when the declared type is a ur-type, is itself a list or union (whose inline
identity cannot be matched), or is an unresolvable reference, so the check biases
to under-reject and never over-rejects. A genuinely derived restriction
substitute, a list/union under an anySimpleType declaration, and a restriction of
a list (which keeps list variety) are all left accepted.

This widening became safe once the derivation backbone was keyed by namespaced
identity (#186); the conservative atomic and built-in not-derived cases remain
under-rejected pending the full type-identity relation.

invalid-instances-accepted 81 to 78, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved. An adversarial
review exercised a list-restriction substitute, a union declared type, ur-type
and typeReference-chain declarations, and cross-namespace list types, and found
no over-rejection.
This commit is contained in:
Mihaela Mihaljevic 2026-06-18 12:55:46 +02:00
parent 1fe670581f
commit 05a0aa4305
4 changed files with 111 additions and 6 deletions

View File

@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- XSTS invalid-instances-accepted (#181, #147): an `xsi:type` naming a list or union simple type is now rejected unless the element's declared type is a ur-type (`anySimpleType`/`anyType`). A list or union type derives only from `anySimpleType` (XSD Part 2), so it cannot validly substitute for an element of any more specific atomic or complex type (`cvc-elt.4.3.2.1`); naming one on, say, an `xs:int`-declared element is invalid. A genuinely derived (restriction) substitute, a list/union under an `anySimpleType` declaration, and a declared type that is itself a list or union are all left alone, so no valid instance is newly rejected. This widening became safe once the derivation backbone was keyed by namespaced identity (#186); the conservative atomic/built-in not-derived cases remain under-rejected pending the full type-identity relation. invalid-instances-accepted 81 → 78, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.
- XSTS invalid-instances-accepted (#186, #147): the instance-validity derivation and substitution tables are now keyed by namespaced identity (`{ns}local`) instead of bare local name, so two same-local-name types or elements in different namespaces no longer collide. `DerivationTables` keeps its bare-keyed fields for the schema-validity machinery (Particle-Valid-Restriction and the consistency rules) and gains parallel namespaced fields built in the same pass; only the instance consumers (the `xsi:type` blocked/not-derived/abstract checks, the substitution-group closure, and `referenceParticle`) read the namespaced tables, reducing a `type:{ns}local` reference, a bare name, or an element ref to a `{ns}local` key. A key miss stays permissive (no constraint), so the re-keying cannot over-reject; the single-document corpus behaviour is unchanged. With the namespaced backbone in place, a schema's `blockDefault` now supplies the `{disallowed substitutions}` (`block`) of every element and complex type that states none (own `block` overrides; `block=""` clears; intersected with the methods each component admits), without one namespace's defaults shadowing another's. invalid-instances-accepted 89 → 81, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0; an adversarial review exercised same- and cross-namespace substitutions, no-targetNamespace keys, transitive substitution groups, and blockDefault override cases), no other bucket moved.
- XSTS invalid-instances-accepted (#147): a complex type's effective attribute wildcard is now the INTERSECTION of the `anyAttribute` wildcards it draws from its own declaration and each referenced attribute group (XSD 1.0 `cos-aw-intersect`), not their union. An attribute is admitted only if every source admits it, so two attribute groups constraining disjoint namespaces (e.g. `urn:a` and `urn:b`) admit nothing and an attribute in either is rejected. A type with a single `anyAttribute` source is unchanged (intersection with no other source is the lone wildcard), and a complexContent extension's union with its base wildcard is unaffected. New `Wildcard.intersection` covers the namespace cases (`##any` identity, enumerated set intersection, enumerated-with-`##other` exclusion) and keeps the less restrictive result for the inexpressible `##other`/`##other` and mismatched `processContents` cases, so the change never over-rejects. invalid-instances-accepted 94 → 89, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.
- XSTS invalid-instances-accepted (#147): identity-constraint (`unique`/`key`/`keyref`) field values are now compared across the decimal-derived numeric family, so two fields typed by different members of that family with the same number collide. A field typed `xs:decimal` with value `1` and a field typed `xs:unsignedByte` with value `1` (e.g. supplied via `xsi:type`) are the same value and violate a `unique`, where previously a difference in declared base type made them compare unequal. The unification is confined to `xs:decimal` and the integer types, which share one value space; distinct primitive spaces (a number versus a string, or `float` versus `double`) are never unified, so no valid instance is newly rejected. invalid-instances-accepted 95 → 94, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

View File

@ -16,10 +16,68 @@ extension PureXML.Schema.ComplexValidator {
) -> PureXML.Validation.ValidationError? {
if let blocked = blockedSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) { return blocked }
if let notDerived = notDerivedSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) { return notDerived }
if let listUnion = listUnionSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) { return listUnion }
if case let .typeReference(name) = declared { return abstractTypeError(named: name, child: child, at: path) }
return nil
}
/// cvc-elt.4.3.2.1 for a list or union `xsi:type`: a list or union simple type
/// is derived only from `anySimpleType`, so it can validly stand in only for an
/// element whose declared type is a ur-type (`anySimpleType`/`anyType`). Naming a
/// list or union type on an element of any more specific atomic or complex type
/// is not a valid derivation. When the declared type is itself a list or union
/// the check stays silent (an inline list/union identity cannot be matched), so
/// it biases to under-reject and never over-rejects.
func listUnionSubstitutionError(
declared: PureXML.Schema.ElementType,
child: PureXML.Model.Element,
at path: [PureXML.Validation.PathKey],
namespaceBindings: [String: String],
) -> PureXML.Validation.ValidationError? {
guard let label = Self.xsiTypeName(child),
let reference = Self.xsiTypeReference(child, namespaceBindings: namespaceBindings),
case let .simple(substitute)? = Self.resolveNamedType(reference, in: types),
Self.isListOrUnion(substitute),
!declaredAdmitsAnySimpleType(declared)
else { return nil }
return PureXML.Validation.ValidationError(
reason: "xsi:type '\(label)' is a list or union type not validly derived from the declared type of '\(child.name.localName)'",
at: path,
)
}
/// Whether a list or union substitute may validly stand in for `declared`: only
/// the ur-types (`anySimpleType`/`anyType`) admit one. A declared type that is
/// itself a list or union, or an unresolvable reference, is also treated as
/// admitting, so the list/union check stays silent rather than risk a false
/// positive on an inline or unknown declared type.
private func declaredAdmitsAnySimpleType(_ declared: PureXML.Schema.ElementType) -> Bool {
switch declared {
case let .simple(type): return type.isAnySimpleType || Self.isListOrUnion(type)
case let .complex(type): return Self.isUrType(type)
case let .typeReference(reference):
guard case let .simple(type)? = Self.resolveNamedType(reference, in: types) else {
if case .complex? = Self.resolveNamedType(reference, in: types) { return urReference(reference) }
return true
}
return type.isAnySimpleType || Self.isListOrUnion(type)
}
}
/// Whether a reference resolving to a complex type is the complex ur-type.
private func urReference(_ reference: String) -> Bool {
guard case let .complex(type)? = Self.resolveNamedType(reference, in: types) else { return false }
return Self.isUrType(type)
}
/// Whether a simple type is a list or union (deriving only from `anySimpleType`).
static func isListOrUnion(_ type: PureXML.Schema.SimpleType) -> Bool {
switch type.variety {
case .atomic: false
case .list, .union: true
}
}
/// The error when an element's declared type is an abstract complex type and
/// the element supplies no `xsi:type` to name a concrete derived type. An
/// abstract type cannot itself be the type of an instance element.

View File

@ -0,0 +1,43 @@
@testable import PureXML
import Testing
/// cvc-elt.4.3.2.1 for a list or union `xsi:type`: a list or union simple type
/// derives only from `anySimpleType`, so it can validly stand in only for an
/// element whose declared type is a ur-type. Naming a list or union type on an
/// element of a more specific atomic type is not a valid derivation and is
/// rejected; a genuinely derived (restriction) substitute, or a list/union under
/// an `anySimpleType` declaration, is still accepted.
@Suite("list/union xsi:type derivation")
struct SchemaListUnionXsiTypeTests {
private func schema(declaredType: String) throws -> PureXML.Schema.Document {
try PureXML.Schema.Document("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="e" type="\(declaredType)"/>
<xs:simpleType name="myList"><xs:list itemType="xs:int"/></xs:simpleType>
<xs:simpleType name="myUnion"><xs:union memberTypes="xs:int xs:string"/></xs:simpleType>
<xs:simpleType name="myInt"><xs:restriction base="xs:int"><xs:minInclusive value="0"/></xs:restriction></xs:simpleType>
</xs:schema>
""")
}
private func doc(_ type: String, _ value: String) -> String {
#"<e xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="\#(type)">\#(value)</e>"#
}
@Test("a list or union xsi:type on an int-declared element is rejected")
func test_listUnionOnAtomicRejected() throws {
#expect(try !schema(declaredType: "xs:int").validate(doc("myList", "1 2")).isEmpty)
#expect(try !schema(declaredType: "xs:int").validate(doc("myUnion", "x")).isEmpty)
}
@Test("a valid restriction xsi:type on an int-declared element is accepted")
func test_restrictionAccepted() throws {
#expect(try schema(declaredType: "xs:int").validate(doc("myInt", "5")).isEmpty)
}
@Test("a list or union xsi:type under anySimpleType is accepted")
func test_listUnionUnderUrTypeAccepted() throws {
#expect(try schema(declaredType: "xs:anySimpleType").validate(doc("myList", "1 2")).isEmpty)
#expect(try schema(declaredType: "xs:anySimpleType").validate(doc("myUnion", "x")).isEmpty)
}
}

View File

@ -151,12 +151,12 @@ struct XSTSSuiteTests {
private let knownSchemaInvalidAccepted = 69
private let knownInstanceValidRejected = 0
/// Instance bucket: xsi:type must derive from the declared type (133 -> 107);
/// block="substitution" bars all members even for an untyped head (107 -> 101);
/// `xsd:anyType` wildcards are `lax` (101 -> 95); identity fields compare in the
/// decimal value space (95 -> 94); a type's attribute wildcard is the INTERSECTION
/// of its sources (94 -> 89); `blockDefault` supplies an element's/type's
/// `block` when it states none, after re-keying derivation by namespace (89 -> 81).
private let knownInstanceInvalidAccepted = 81
/// block="substitution" bars members even for an untyped head (107 -> 101); ur-type
/// `xsd:anyType` wildcards are `lax` (101 -> 95); identity fields compare in the decimal
/// value space (95 -> 94); attribute wildcard is the INTERSECTION of its sources (94 -> 89);
/// `blockDefault` supplies a missing `block` after re-keying derivation by namespace
/// (89 -> 81); a list/union xsi:type is not derived from a non-ur type (81 -> 78).
private let knownInstanceInvalidAccepted = 78
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {