fix(schema): enforce block="substitution" on substitution-group heads

The substitution filter dropped only members whose type derived from the head's
type by a blocked method (extension/restriction). The `substitution` keyword in
a head's block forbids substitution-group substitution outright, whatever the
member's type, and was ignored, so a same-type member still substituted for a
head declared block="substitution". Members are now dropped entirely when the
head blocks substitution.

XSTS: invalid instances accepted 238 to 231, valid schemas rejected 75 to 74.
Baseline updated; regression tests cover the substitution block rejecting a
member, plain heads still validating, and the no-block control.

Refs #147, #129.
This commit is contained in:
Mihaela Mihaljevic 2026-06-13 18:12:18 +02:00
parent c9f41c387e
commit 47e20ff90b
4 changed files with 48 additions and 2 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- `block="substitution"` on a substitution-group head is now enforced (#147). The substitution filter only dropped members whose type derived from the head's type by a blocked method (`extension`/`restriction`); the `substitution` keyword, which forbids substitution-group substitution outright regardless of the member's type, was ignored, so a same-type member still substituted under a substitution block. XSTS: invalid instances accepted 238 to 231, valid schemas rejected 75 to 74.
- Element `default` and `fixed` values are honored on empty elements (#147). An empty element with a value constraint takes that value, so the empty content is valid even when the type would reject the empty string; the constraint value (not the empty string) is what is validated against the type, including any `xsi:type` override. A restriction's `enumeration` now also replaces the inherited enumeration rather than unioning with it (the new set is the value space; `pattern` still accumulates and is ANDed), which a value-constraint case under `xsi:type` exposed. XSTS: valid instances rejected 395 to 369, invalid instances accepted 240 to 238.
- Element-level `block` is now enforced against `xsi:type` substitution (#147). The `block` on an element declaration (distinct from `block` on its type) was parsed but never threaded to the instance validator, so a document substituting a blocked-derivation type via `xsi:type` was wrongly accepted. The element block is now carried through to `blockedSubstitutionError`, which also resolves the element-ref type indirection (`element:foo` to its concrete type) so the check fires for referenced children, not only the root. XSTS: invalid instances accepted 261 to 240.
- The regex engine now recognizes the full XSD `\p{Is<Block>}` Unicode block set (#147). Only about fifteen blocks were tabulated, so a pattern over any other block (`\p{IsArmenian}`, `\p{IsTamil}`, `\p{IsHangulJamo}`, and so on) failed to compile and rejected valid instances. The table now carries the Unicode 3.1 blocks the XSD regular-expression appendix fixes (surrogate blocks omitted, since surrogates are not scalar values); each range is verified bidirectionally by the XSTS corpus (valid in-block instances pass, invalid out-of-block instances still fail). XSTS: valid instances rejected 502 to 395.

View File

@ -137,6 +137,9 @@ extension PureXML.Schema.XSDParser {
continue
}
result[head] = members.filter { member in
// `block="substitution"` forbids substitution-group substitution
// outright: no member may stand in for the head, whatever its type.
guard !blocked.contains(.substitution) else { return false }
guard let memberType = tables.elementTypeNames[member],
let methods = derivationMethods(from: memberType, to: headType, tables.typeDerivation)
else {

View File

@ -0,0 +1,42 @@
@testable import PureXML
import Testing
/// `block="substitution"` on a substitution-group head forbids any member from
/// standing in for it, regardless of the member's type derivation (#147, XSTS
/// disallowedSubst set). Only type-derivation blocks (`extension`/`restriction`)
/// had been applied, so a same-type member still substituted under a
/// substitution block.
@Suite("Substitution-group block")
struct SchemaSubstitutionBlockTests {
private func schema(headBlock: String) throws -> PureXML.Schema.Document {
let attribute = headBlock.isEmpty ? "" : " block=\"\(headBlock)\""
return 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" maxOccurs="unbounded"/>
</xs:sequence></xs:complexType>
</xs:element>
<xs:element name="Head" type="T"\(attribute)/>
<xs:complexType name="T"><xs:sequence><xs:element name="x" type="xs:string"/></xs:sequence></xs:complexType>
<xs:element name="Member" type="T" substitutionGroup="Head"/>
</xs:schema>
""")
}
private let withMember = #"<root xmlns="urn:s"><Head><x>a</x></Head><Member><x>b</x></Member></root>"#
private let headsOnly = #"<root xmlns="urn:s"><Head><x>a</x></Head><Head><x>b</x></Head></root>"#
@Test("block='substitution' on the head rejects a substituting member")
func test_substitutionBlocked() throws {
#expect(try !schema(headBlock: "substitution").validate(withMember).isEmpty)
// Plain heads are still fine.
#expect(try schema(headBlock: "substitution").validate(headsOnly).isEmpty)
}
@Test("without a substitution block the member may substitute")
func test_memberAllowedWithoutBlock() throws {
#expect(try schema(headBlock: "").validate(withMember).isEmpty)
}
}

View File

@ -28,10 +28,10 @@ struct XSTSSuiteTests {
/// `swift test` are unaffected; run it with `swift test -c release --filter
/// XSTS` (debug is far slower and the corpus is large). Per-case deviations
/// are written to /tmp/xsts-failures.txt for the burn-down.
private let knownSchemaValidRejected = 75
private let knownSchemaValidRejected = 74
private let knownSchemaInvalidAccepted = 2467
private let knownInstanceValidRejected = 369
private let knownInstanceInvalidAccepted = 238
private let knownInstanceInvalidAccepted = 231
@Test("Every XSTS case behaves: compile, reject, validate, invalidate")
func test_suite() throws {