From 47e20ff90be2154052ae13d4fde6dfb98b64a572 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 13 Jun 2026 18:12:18 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + Sources/Schema/XSDDerivation.swift | 3 ++ Tests/SchemaSubstitutionBlockTests.swift | 42 ++++++++++++++++++++++++ Tests/XSTSSuiteTests.swift | 4 +-- 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 Tests/SchemaSubstitutionBlockTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 34da6ba..6229b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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}` 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. diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift index bba3def..16aed07 100644 --- a/Sources/Schema/XSDDerivation.swift +++ b/Sources/Schema/XSDDerivation.swift @@ -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 { diff --git a/Tests/SchemaSubstitutionBlockTests.swift b/Tests/SchemaSubstitutionBlockTests.swift new file mode 100644 index 0000000..06bca8c --- /dev/null +++ b/Tests/SchemaSubstitutionBlockTests.swift @@ -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(""" + + + + + + + + + + + """) + } + + private let withMember = #"ab"# + private let headsOnly = #"ab"# + + @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) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 4dd60b9..d9d1ede 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -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 {