fix(schema): bar a substitution-group member blocked by the head's type; invalid-instances-accepted 53 to 50 (#147)

A block on a substitution-group head's TYPE (its {prohibited substitutions}), not
only on the head element, bars a member whose type reaches the head's type by the
blocked derivation method. filterSubstitutions consulted only the head element's
own block, so block="restriction" on the head's complex type let a
restriction-derived member substitute.

The substitution-group filter now drops a member when its derivation method to the
head's type is in the union of the head element's {disallowed substitutions} and
the head type's {prohibited substitutions}. The element-level block="substitution"
outright bar stays element-only (a type's prohibited-substitutions never carries
substitution), and a member derived by a method the block does not list still
substitutes.

invalid-instances-accepted 53 to 50, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0). An adversarial review confirmed members
derived by an allowed method, the positive disallowedSubst instances, transitive
chains, and blockDefault interactions all still substitute. (A separate
cross-document cross-namespace substitution-group rejection was confirmed
pre-existing on HEAD, not introduced here.)
This commit is contained in:
Mihaela Mihaljevic 2026-06-18 14:02:14 +02:00
parent b3a2bcfe6a
commit 75bb89dac7
4 changed files with 62 additions and 12 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 (#147): a `block` on a substitution-group head's TYPE, not only on the head element, now bars a member from substituting. The substitution-group filter drops a member whose type reaches the head's type by a blocked derivation method, using the union of the head element's `{disallowed substitutions}` and the head type's `{prohibited substitutions}`; previously only the head element's own `block` was consulted, so a `block="restriction"` on the head's complex type let a restriction-derived member substitute. The element-level `block="substitution"` outright bar is unchanged, and a member derived by a method the block does not list still substitutes. invalid-instances-accepted 53 → 50, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.
- XSTS invalid-instances-accepted (#181, #147): the `xsi:type` not-derived check now also flags a complex substitute that records no base of its own. Such a type derives only from `anyType`, so it cannot validly substitute for a non-ur complex declared type; naming the declared type's own complex ancestor, or an unrelated baseless complex type, is rejected. A complex substitute is in scope even when absent from the derivation backbone (its non-derivation from a non-ur type is certain), while an atomic substitute must still be in the backbone so a built-in stays silent. A genuine extension/restriction substitute is accepted, and a baseless complex substitute under an `anyType` declaration is admitted. invalid-instances-accepted 60 → 53, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.
- XSTS invalid-instances-accepted (#181, #147): the `xsi:type` not-derived check (`cvc-elt.4.3.2.1`) now covers ATOMIC simple substitutes, not only complex ones. An `xsi:type` naming an atomic type that is not derived from the element's declared type, such as a sibling restriction or the declared type's own ancestor, is rejected. The check still fires only when both the substitute and the declared type are recorded in the namespaced derivation backbone with no path between them, so a built-in substitute or declared type (absent from the backbone) and the ur-types stay silent (under-reject), avoiding any built-in-derivation false positive; a genuinely derived restriction substitute is accepted. invalid-instances-accepted 66 → 60, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved.

View File

@ -302,24 +302,28 @@ extension PureXML.Schema.XSDParser {
static func filterSubstitutions(_ subs: [String: [String]], _ tables: DerivationTables) -> [String: [String]] {
var result: [String: [String]] = [:]
for (head, members) in subs {
guard let blocked = tables.nsElementBlock[head] else {
result[head] = members
continue
}
let elementBlock = tables.nsElementBlock[head] ?? []
// `block="substitution"` (or a block set containing it, including `#all`)
// forbids substitution-group substitution outright: no member may stand
// in for the head, whatever its type, and whether or not the head is
// typed. This is independent of the type-derivation block below.
if blocked.contains(.substitution) {
// on the head element forbids substitution-group substitution outright:
// no member may stand in, whatever its type. A type's `{prohibited
// substitutions}` never carries `substitution`, so this stays element-only.
if elementBlock.contains(.substitution) {
result[head] = []
continue
}
// The type-derivation block (extension/restriction) drops only members
// reaching the head's type by a blocked method; it needs the head's type.
// The extension/restriction block drops a member reaching the head's type
// by a blocked method. The effective block is the union of the head
// element's `{disallowed substitutions}` and the head type's `{prohibited
// substitutions}`, so a `block` on the head's TYPE bars the substitution too.
guard let headType = tables.nsElementTypeNames[head] else {
result[head] = members
continue
}
let blocked = elementBlock.union(tables.nsTypeBlock[headType] ?? [])
guard !blocked.isEmpty else {
result[head] = members
continue
}
result[head] = members.filter { member in
guard let memberType = tables.nsElementTypeNames[member],
let methods = derivationMethods(from: memberType, to: headType, tables.nsTypeDerivation)

View File

@ -0,0 +1,44 @@
@testable import PureXML
import Testing
/// A `block` on a substitution-group head's TYPE (its `{prohibited substitutions}`),
/// not only on the head element, bars a member whose type reaches the head's type
/// by the blocked derivation method. The substitution-group filter unions the head
/// element's `{disallowed substitutions}` with the head type's `{prohibited
/// substitutions}` (W3C sun disallowedSubst set).
@Suite("Substitution-group type block")
struct SchemaSubstitutionTypeBlockTests {
private func schema(typeBlock: String) throws -> PureXML.Schema.Document {
try PureXML.Schema.Document("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="urn:s" targetNamespace="urn:s"
elementFormDefault="qualified">
<xs:element name="root"><xs:complexType><xs:sequence>
<xs:element ref="Head"/>
</xs:sequence></xs:complexType></xs:element>
<xs:complexType name="Type" block="\(typeBlock)"><xs:sequence/></xs:complexType>
<xs:element name="Head" type="Type"/>
<xs:complexType name="Sub"><xs:complexContent><xs:restriction base="Type">
<xs:sequence/>
</xs:restriction></xs:complexContent></xs:complexType>
<xs:element name="Member" type="Sub" substitutionGroup="Head"/>
</xs:schema>
""")
}
private let withMember = #"<root xmlns="urn:s"><Member/></root>"#
private let withHead = #"<root xmlns="urn:s"><Head/></root>"#
@Test("a type block='restriction' on the head's type bars a restriction member")
func test_typeBlockBarsRestrictionMember() throws {
// Member's type restricts Type, which blocks restriction: the substitution is barred.
#expect(try !schema(typeBlock: "restriction").validate(withMember).isEmpty)
// The head itself still validates.
#expect(try schema(typeBlock: "restriction").validate(withHead).isEmpty)
}
@Test("a type block='extension' does not bar a restriction member")
func test_typeBlockOtherMethodAllowsMember() throws {
// Member derives by restriction; an extension-only block does not bar it.
#expect(try schema(typeBlock: "extension").validate(withMember).isEmpty)
}
}

View File

@ -150,13 +150,13 @@ struct XSTSSuiteTests {
private let knownSchemaValidRejected = 0
private let knownSchemaInvalidAccepted = 69
private let knownInstanceValidRejected = 0
/// Instance bucket (133 -> 53), per-step deltas in CHANGELOG.md: xsi:type must derive from the
/// Instance bucket (133 -> 50), 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);
/// block/blockDefault (incl. "substitution" and an untyped head) bar substitutions after the
/// derivation backbone was re-keyed by namespace; ur-type `xsd:anyType` wildcards are `lax`;
/// identity fields compare in the decimal value space; a complex type's attribute wildcard is the
/// INTERSECTION of its `anyAttribute` sources.
private let knownInstanceInvalidAccepted = 53
private let knownInstanceInvalidAccepted = 50
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {