fix(schema): an xsi:type must be derived from the declared type (cvc-elt.4.3.2.1); invalid-instances-accepted 133 to 107 (#181)

First instance-bucket reduction of the campaign. An instance xsi:type naming a
complex type must be derived from the element's declared type. notDerivedSubstitutionError
flags a complex substitute on a different branch of the hierarchy; previously
only a derived-but-blocked substitution was caught (blockedSubstitutionError
returns nil when there is no derivation path), so a non-derived xsi:type was
silently accepted (the test006 cluster, 32 instances, and more).

Conservatively scoped after a broader first cut took 20 false positives (now
reverted): it fires only when the substitute resolves to a COMPLEX type and both
substitute and declared names are in the derivation backbone with no path between
them. Simple/list/union substitutes, ur-types, and unresolvable references are
left alone -- under-reject, never over-reject. An adversarial review confirmed
the conservative guards eliminate the prior false positives; the one residual is
the pre-existing bare-local-name keying of the derivation backbone (shared with
blockedSubstitutionError), disclosed in the code, corpus-absent.

The three xsi:type override checks (blocked / not-derived / abstract) are folded
into one xsiTypeOverrideError helper to keep validateChild under the complexity limit.

XSTS: invalid-instances-accepted 133 to 107 (26 caught), valid-instances-rejected
and valid-schemas-rejected both held at 0, no other bucket moved.
This commit is contained in:
Mihaela Mihaljevic 2026-06-18 10:07:05 +02:00
parent 0994f5dd2a
commit 8e873dc929
5 changed files with 111 additions and 7 deletions

View File

@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- XSTS invalid-instances-accepted (#181, #147): an instance `xsi:type` naming a complex type must be derived from the element's declared type (`cvc-elt.4.3.2.1`). A complex `xsi:type` on a different branch of the type hierarchy (not derived from the declared type) is now rejected, where previously only a derived-but-blocked substitution was caught. Conservatively scoped to substitutes that resolve to a complex type recorded in the derivation backbone, so simple/list/union substitutes and unresolvable references are left alone (no over-rejection). invalid-instances-accepted 133 → 107, no false positive (valid-instances-rejected held at 0, valid-schemas-rejected held at 0), no other bucket moved. Disclosed bound: the derivation backbone is keyed by bare local name (shared with the existing block-substitution rule), so a cross-namespace same-local-name collision could in principle be misjudged; the corpus exercises none, and the holistic fix is to key the backbone by namespaced type identity.
- XSTS invalid-schemas-accepted (#145): an `xs:include` and `xs:redefine` require a `schemaLocation` attribute (only `xs:import`'s is optional); one declared without it is rejected. invalid-schemas-accepted 71 → 69, no false positive (valid-schemas-rejected held at 0; a required-attribute check with no name resolution cannot over-reject), no other bucket moved.
- XSTS invalid-schemas-accepted (#161): a schema referenced by `xs:redefine` must have the same `targetNamespace` as the redefining schema, or none (chameleon), exactly as for `xs:include`; redefining a different-namespace schema is rejected. The existing include-namespace-matching check now also covers `redefine`, selecting the loaded redefined schema document (not the redefine wrapper) so its real target namespace is compared. invalid-schemas-accepted 74 → 71, no false positive (valid-schemas-rejected held at 0; an adversarial review confirmed same-namespace, chameleon, and no-namespace redefines are not flagged), no other bucket moved.

View File

@ -273,12 +273,8 @@ public extension PureXML.Schema {
// reference so re-entry does not re-read the same xsi:type. A `block` on
// the declared type can forbid the substitution; an abstract declared
// type requires one.
if let blocked = blockedSubstitutionError(declared: declared, child: child, at: path) {
errors.append(blocked)
return
}
if case let .typeReference(name) = declared, let missing = abstractTypeError(named: name, child: child, at: path) {
errors.append(missing)
if let xsiError = xsiTypeOverrideError(declared: declared, child: child, at: path) {
errors.append(xsiError)
return
}
guard let declared = overriddenType(declared, for: child, namespaceBindings: namespaceBindings, at: path, into: &errors) else { return }

View File

@ -1,4 +1,18 @@
extension PureXML.Schema.ComplexValidator {
/// The first `xsi:type` override error for `child`, if any: a substitution
/// blocked by `block`, a substitute not validly derived from the declared type
/// (cvc-elt.4.3.2.1), or a missing `xsi:type` on an abstract declared type.
func xsiTypeOverrideError(
declared: PureXML.Schema.ElementType,
child: PureXML.Model.Element,
at path: [PureXML.Validation.PathKey],
) -> PureXML.Validation.ValidationError? {
if let blocked = blockedSubstitutionError(declared: declared, child: child, at: path) { return blocked }
if let notDerived = notDerivedSubstitutionError(declared: declared, child: child, at: path) { return notDerived }
if case let .typeReference(name) = declared { return abstractTypeError(named: name, child: child, at: path) }
return nil
}
/// 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.
@ -61,4 +75,68 @@ extension PureXML.Schema.ComplexValidator {
at: path,
)
}
/// cvc-elt.4.3.2.1: an `xsi:type` must resolve to a type DERIVED from the
/// element's declared type. This flags ONLY the case where both the declared
/// type and the substitute resolve to COMPLEX types recorded in the derivation
/// backbone and there is no derivation path between them (a different branch of
/// the hierarchy). It deliberately does NOT touch a simple/list/union substitute,
/// a substitute or declared type absent from the backbone, or the ur-types: there
/// the relationship cannot be confirmed from the complex-type backbone alone, so
/// the rule stays silent (under-reject) rather than risk a false positive.
///
/// Disclosed bound: the derivation backbone (`typeDerivation`) is keyed by bare
/// local name, shared with `blockedSubstitutionError`. Two imported complex types
/// with the same local name in different namespaces collide in one slot, so a
/// cross-namespace substitution could in principle be misjudged. This is a
/// pre-existing, subsystem-wide limitation (not introduced here); the holistic
/// fix is to key the backbone by namespaced type identity and resolve `xsi:type`
/// through `xsiTypeReference`. The XSTS corpus exercises no such collision.
func notDerivedSubstitutionError(
declared: PureXML.Schema.ElementType,
child: PureXML.Model.Element,
at path: [PureXML.Validation.PathKey],
) -> PureXML.Validation.ValidationError? {
guard case let .typeReference(reference) = declared,
let substitute = Self.xsiTypeName(child),
case .complex? = Self.resolveNamedType(substitute, in: types)
else { return nil }
let substituteName = PureXML.Schema.XSDParser.bareTypeLocalName(substitute)
let declaredName = resolvedDeclaredTypeName(reference)
guard substituteName != declaredName,
typeDerivation[substituteName] != nil,
typeDerivation[declaredName] != nil || isComplexBackboneRoot(declaredName),
PureXML.Schema.XSDParser.derivationMethods(from: substituteName, to: declaredName, typeDerivation) == nil
else { return nil }
return PureXML.Validation.ValidationError(
reason: "xsi:type '\(substitute)' is not validly derived from the declared type '\(declaredName)' of '\(child.name.localName)'",
at: path,
)
}
/// A complex type that heads a derivation backbone (some recorded type derives
/// from it) even though it records no base of its own, so it is a legitimate
/// declared-type target for the not-derived check.
private func isComplexBackboneRoot(_ name: String) -> Bool {
guard name != "anyType", name != "anySimpleType" else { return false }
return typeDerivation.values.contains { $0.base == name }
}
/// Follows an element-ref / type-reference chain (`element:foo` -> concrete type)
/// to the declared type's bare local name, which the derivation backbone is
/// keyed by.
private func resolvedDeclaredTypeName(_ reference: String) -> String {
var key = reference
var name = PureXML.Schema.XSDParser.bareTypeLocalName(reference)
var steps = 0
while steps <= types.count {
guard let resolved = types[key] ?? Self.resolveNamedType(key, in: types),
case let .typeReference(next) = resolved
else { break }
key = next
name = PureXML.Schema.XSDParser.bareTypeLocalName(next)
steps += 1
}
return name
}
}

View File

@ -39,4 +39,28 @@ struct SchemaSubstitutionBlockTests {
func test_memberAllowedWithoutBlock() throws {
#expect(try schema(headBlock: "").validate(withMember).isEmpty)
}
/// cvc-elt.4.3.2.1: an xsi:type naming a complex type must be derived from the
/// element's declared type. A complex type on a different branch of the
/// hierarchy is rejected; the declared type itself is valid.
@Test("an xsi:type must be derived from the element's declared type")
func test_xsiTypeMustDeriveFromDeclared() throws {
let schema = try PureXML.Schema.Document("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="urn:s" targetNamespace="urn:s" elementFormDefault="qualified">
<xs:complexType name="B"><xs:sequence><xs:element name="f" type="xs:string"/></xs:sequence></xs:complexType>
<xs:complexType name="De"><xs:complexContent><xs:extension base="B"/></xs:complexContent></xs:complexType>
<xs:complexType name="Dr"><xs:complexContent><xs:restriction base="B">
<xs:sequence><xs:element name="f" type="xs:string"/></xs:sequence>
</xs:restriction></xs:complexContent></xs:complexType>
<xs:element name="e" type="De"/>
</xs:schema>
""")
func doc(_ type: String) -> String {
#"<e xmlns="urn:s" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="\#(type)"><f>x</f></e>"#
}
// Dr (restriction of B) is on a different branch, not derived from De: invalid.
#expect(try !schema.validate(doc("Dr")).isEmpty)
// The declared type itself is valid.
#expect(try schema.validate(doc("De")).isEmpty)
}
}

View File

@ -150,7 +150,11 @@ struct XSTSSuiteTests {
private let knownSchemaValidRejected = 0
private let knownSchemaInvalidAccepted = 69
private let knownInstanceValidRejected = 0
private let knownInstanceInvalidAccepted = 133
/// Instance bucket: an xsi:type naming a complex type must be derived from the
/// element's declared type (cvc-elt.4.3.2.1); a non-derived complex substitute is
/// rejected, conservatively scoped to the complex-type derivation backbone so no
/// valid instance is over-rejected. 133 -> 107.
private let knownInstanceInvalidAccepted = 107
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {