From 94e16b1d7d8110a243140710fb49c577d8b4c851 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 21:12:35 +0200 Subject: [PATCH] fix(schema): block an xsi:type that reaches a union declared type through a member (#277) cvc-elt.4.3 defers to cos-st-derived-OK, whose clause 2.2.4 makes a type validly derived from a union when it is validly derived from one of the union's member types. The block check followed only the named base chain, so when the declared type was a union and the substitute reached it through a member by a blocked derivation method, the check found no path and stayed silent, wrongly accepting the substitution (W3C XSTS elemT074: block on the element is restriction and the xsi:type restricts a union member). Record a union's direct member types during schema compilation and follow a derivation through a member when the base chain does not reach the union. The member clause is existential: bar the substitution only when every confirmed derivation path to a member is blocked, so a substitute that is itself a member (its self-membership path uses no method) or reaches one member by an unblocked method is accepted whatever other members it reaches by a blocked method. The check adds a rejection only on a positively confirmed path, so an unresolved, inline-anonymous, or nested-union member leaves it silent: a bounded, disclosed under-rejection, never a false positive. Drops XSTS invalid-instances-accepted 15 to 14 with valid-schemas-rejected and valid-instances-rejected held at 0 over the full archive. An adversarial over-rejection review caught a first cut that order-shadowed the self-membership path; the existential form rules it out and the case is a regression test. --- CHANGELOG.md | 4 + Sources/Schema/ComplexValidator.swift | 6 ++ .../Schema/ComplexValidatorDerivation.swift | 43 ++++++++- Sources/Schema/SchemaDocument.swift | 7 ++ Sources/Schema/XSDContext.swift | 4 + Sources/Schema/XSDDerivation.swift | 4 + Sources/Schema/XSDDerivationTables.swift | 12 +++ Sources/Schema/XSDParser.swift | 1 + Sources/Schema/XSDValidations.swift | 4 + .../SchemaXSITypeUnionMemberBlockTests.swift | 96 +++++++++++++++++++ Tests/XSTSSuiteTests.swift | 8 +- 11 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 Tests/SchemaXSITypeUnionMemberBlockTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c1bff24..84c0699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- An `xsi:type` substitution blocked by `block`/`blockDefault` is now rejected even when the substitute reaches the declared type through a union member rather than along its own base chain (cvc-elt.4.3 with cos-st-derived-OK clause 2.2.4: a type validly derived from a union member is validly derived from the union, and the `{disallowed substitutions}` apply to the derivation method used to reach that member). Previously the block check followed only the named base chain, found no path from the substitute to the declared union, and stayed silent, so a substitution reaching a union member by a blocked method was wrongly accepted (W3C XSTS `elemT074`: `block="restriction"` on the element, the `xsi:type` restricting a union member). A name-keyed union-member table is recorded during schema compilation and the block check now follows a derivation through a member; it adds a rejection only on a positively confirmed path, so an unresolved or inline-anonymous member leaves the check silent rather than risk a false positive. This drops XSTS `invalid-instances-accepted` 15 -> 14 while holding `valid-schemas-rejected` and `valid-instances-rejected` at 0 (full XSTS archive, the false-positive direction the production bar guards hardest). The member clause is existential (cos-st-derived-OK 2.2.4): the substitution is barred only when EVERY confirmed derivation path to a member is blocked, so a substitute that is itself a union member, or reaches one member by an unblocked method, is accepted whatever other members it also reaches by a blocked method. Disclosed under-rejection (bounded, not silent): only a union's DIRECT `memberTypes` are recorded, not flattened, so a substitution reaching the declared union through a member that is ITSELF a union (a nested-union member-of-a-member path) is not blocked; this can only under-reject (accept an invalid substitution), never over-reject, because an unmodeled path yields no confirmed derivation and the check stays silent rather than guess. Guarded by `SchemaXSITypeUnionMemberBlockTests`, which checks the rejection, that a non-blocked method (and no block) still accepts the valid substitution, and that a substitute which is itself a member is accepted even when listed after another member it derives from by a blocked method (the over-rejection an adversarial review caught and this design rules out). + ### Changed - Parser throughput on plain-ASCII documents roughly halved against the libxml2 reference (measured `parse` 20.1x -> 9.5x slower than libxml2 on the 20k-item generated corpus, `bash scripts/benchmark.sh 20000 5`; ~53% wall-clock reduction). The work was profile-driven (macOS `sample`, not guesswork): the scanner, not tree construction, dominated parse time (SAX is ~84% of parse), and within it the cost was per-`Character` `String` machinery (`String.Iterator.next`, `_stringCompareWithSmolCheck`, `_uncheckedFromUTF8`, bridge-object ARC). The root cause was that a single buffered lookahead `Character` defeated every existing byte fast path (each requires an empty buffer), and the content/markup/attribute dispatch all peeked a `Character` before scanning. The fix keeps the reader on the byte path: a non-buffering `peekByte` for dispatch, a byte-mode `skipSpace`, an all-ASCII `takeASCIIName`, an `attributeRunBytes` bulk scanner (with the 3.3.3 whitespace-to-space transform, deferring any carriage return to the Character path so CRLF is not double-counted), and a `sawAmpersand` flag so ampersand-free text skips reference decoding entirely. The single highest-leverage change was detecting the closing `<` of a text run via `peekByte` instead of buffering it, which had been cascading the next element's whole dispatch and name scan onto the slow path. The Character path remains the exact fallback for a streaming source, spliced entity text, and any non-ASCII byte; byte and Character paths are kept from diverging by `scanContentMarkup` (shared dispatch) and by `ByteFastPathTests`, which pins the fast-path/fallback boundaries (non-ASCII names, CRLF and character references in attribute values, ampersand-free vs entity-bearing text, over-long names, raw `<` in attributes). Verified against the full local suite (1668 tests) plus the Apache Xalan XSLT gold-output corpus, the W3C XML conformance corpora (xmltest, OASIS, Sun, Eduni, IBM, Japanese), and the RELAX NG spec suite, all green; no spec behavior changed. diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index c4b05a8..dd73af1 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -39,6 +39,10 @@ public extension PureXML.Schema { /// Each named complex type's base and derivation method, keyed by namespaced /// identity (`{ns}local`). let typeDerivation: [String: TypeDerivation] + /// Each named union type's member type keys (`{ns}local`), so the + /// `xsi:type`-`block` check can follow a derivation that reaches a declared + /// union through one of its members (cos-st-derived-OK 2.2.4). + let unionMembers: [String: [String]] /// Global attribute declarations for strict/lax `anyAttribute` validation. let globalAttributes: [String: AttributeUse] /// Document-scoped xs:ID/xs:IDREF accumulator, filled during the typed walk @@ -55,6 +59,7 @@ public extension PureXML.Schema { typeBlock: [String: Set] = [:], elementBlock: [String: Set] = [:], typeDerivation: [String: TypeDerivation] = [:], + unionMembers: [String: [String]] = [:], ) { self.types = types self.globalAttributes = globalAttributes @@ -64,6 +69,7 @@ public extension PureXML.Schema { self.typeBlock = typeBlock self.elementBlock = elementBlock self.typeDerivation = typeDerivation + self.unionMembers = unionMembers } /// Validates `element` against `type` at `path`, one error per violation. diff --git a/Sources/Schema/ComplexValidatorDerivation.swift b/Sources/Schema/ComplexValidatorDerivation.swift index 7bfc90a..475eac1 100644 --- a/Sources/Schema/ComplexValidatorDerivation.swift +++ b/Sources/Schema/ComplexValidatorDerivation.swift @@ -198,14 +198,11 @@ extension PureXML.Schema.ComplexValidator { } let substituteKey = Self.derivationKey(fromReference: substituteRef) let declaredName = resolvedDeclaredTypeName(reference) - guard let methods = PureXML.Schema.XSDParser.derivationMethods(from: substituteKey, to: declaredName, typeDerivation) else { - return nil - } // The substitution is blocked when the derivation method is disallowed by // either the declared type's `block` or the element declaration's own // `block`, both keyed by namespaced identity. let blocked = (typeBlock[declaredName] ?? []).union(elementBlock[Self.key(child.name)] ?? []) - guard !methods.isDisjoint(with: blocked) else { + guard isSubstitutionBlocked(from: substituteKey, to: declaredName, by: blocked) else { return nil } return PureXML.Validation.ValidationError( @@ -214,6 +211,44 @@ extension PureXML.Schema.ComplexValidator { ) } + /// Whether the `block` set bars deriving `substitute` from `declared`. The + /// derivation is along the base chain, or, when `declared` is a union, through + /// any one of its members (cos-st-derived-OK 2.2.4). That member clause is + /// EXISTENTIAL: the substitution is valid as long as ONE member yields an + /// unblocked derivation, so a substitute that IS a member (the empty-method + /// self-membership path) or reaches one member by an unblocked method is never + /// blocked, whatever other members it also reaches by a blocked method. With no + /// confirmed path at all the check stays silent (under-reject) rather than risk + /// a false positive. + private func isSubstitutionBlocked( + from substitute: String, + to declared: String, + by blocked: Set, + ) -> Bool { + if let direct = PureXML.Schema.XSDParser.derivationMethods(from: substitute, to: declared, typeDerivation) { + return !direct.isDisjoint(with: blocked) + } + // `unionMembers` records a union's DIRECT members only, not flattened, so a + // path reaching `declared` through a member that is itself a union is not + // modeled: that member yields no base-chain derivation here, leaving the + // check silent. A bounded, disclosed under-rejection, never an over-rejection. + guard let members = unionMembers[declared] else { return false } + var anyConfirmedPath = false + for member in members { + // The substitute IS a member: derived with no method of its own, so + // this path is unblocked and the substitution is permitted outright. + if member == substitute { return false } + guard let toMember = PureXML.Schema.XSDParser.derivationMethods(from: substitute, to: member, typeDerivation) else { + continue + } + anyConfirmedPath = true + // An unblocked path to any member makes the whole substitution valid. + if toMember.isDisjoint(with: blocked) { return false } + } + // Every confirmed member path is blocked (and at least one exists): bar it. + return anyConfirmedPath + } + /// cvc-elt.4.3.2.1: an `xsi:type` must resolve to a type DERIVED from the /// element's declared type. This flags the case where both the declared type and /// the substitute resolve to complex or atomic-simple types recorded in the diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index d85adf8..9f3997c 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -54,6 +54,7 @@ public extension PureXML.Schema { private let typeBlock: [String: Set] private let elementBlock: [String: Set] private let typeDerivation: [String: TypeDerivation] + private let unionMembers: [String: [String]] private let targetNamespace: String? private let globalAttributes: [String: AttributeUse] @@ -73,6 +74,7 @@ public extension PureXML.Schema { typeBlock: [String: Set], elementBlock: [String: Set], typeDerivation: [String: TypeDerivation], + unionMembers: [String: [String]], targetNamespace: String?, globalAttributes: [String: AttributeUse], ) { @@ -88,6 +90,7 @@ public extension PureXML.Schema { self.typeBlock = typeBlock self.elementBlock = elementBlock self.typeDerivation = typeDerivation + self.unionMembers = unionMembers self.targetNamespace = targetNamespace self.globalAttributes = globalAttributes } @@ -117,6 +120,7 @@ public extension PureXML.Schema { typeBlock: mineFirst(typeBlock, other.typeBlock), elementBlock: mineFirst(elementBlock, other.elementBlock), typeDerivation: mineFirst(typeDerivation, other.typeDerivation), + unionMembers: mineFirst(unionMembers, other.unionMembers), targetNamespace: targetNamespace, globalAttributes: mineFirst(globalAttributes, other.globalAttributes), ) @@ -140,6 +144,7 @@ public extension PureXML.Schema { typeBlock = compiled.nsTypeBlock elementBlock = compiled.nsElementBlock typeDerivation = compiled.nsTypeDerivation + unionMembers = compiled.nsUnionMembers targetNamespace = compiled.targetNamespace globalAttributes = compiled.globalAttributes // Schema consistency through the validation framework: every named @@ -228,6 +233,7 @@ public extension PureXML.Schema { typeBlock: typeBlock, elementBlock: elementBlock, typeDerivation: typeDerivation, + unionMembers: unionMembers, ) var driver = PureXML.Validation.StreamingXSDValidator( validator: validator, @@ -292,6 +298,7 @@ public extension PureXML.Schema { typeBlock: typeBlock, elementBlock: elementBlock, typeDerivation: typeDerivation, + unionMembers: unionMembers, globalAttributes: globalAttributes, identityFieldTypes: identityFieldTypes, identityFieldConstraints: identityFieldConstraints, diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index 02c064d..0e47509 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -222,6 +222,10 @@ extension PureXML.Schema { var nsTypeBlock: [String: Set] = [:] var nsElementBlock: [String: Set] = [:] var nsTypeDerivation: [String: TypeDerivation] = [:] + /// Each named union simple type's member type keys (`{ns}local`), so the + /// `xsi:type`-`block` check can follow a derivation that reaches the + /// declared union through one of its members (cos-st-derived-OK 2.2.4). + var nsUnionMembers: [String: [String]] = [:] /// Derivation methods each named type declares `final`, for the /// schema-consistency rules. var typeFinal: [String: Set] = [:] diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift index 385b199..51ad335 100644 --- a/Sources/Schema/XSDDerivation.swift +++ b/Sources/Schema/XSDDerivation.swift @@ -46,6 +46,10 @@ extension PureXML.Schema.XSDParser { var nsElementBlock: [String: Set] = [:] /// Namespaced element name (`{ns}elem`) to namespaced declared type (`{ns}type`). var nsElementTypeNames: [String: String] = [:] + /// Namespaced union type (`{ns}local`) to its member type keys (`{ns}local`), + /// so the `xsi:type`-`block` check can follow a derivation that reaches a + /// declared union through one of its members. + var nsUnionMembers: [String: [String]] = [:] } /// Parses a `block`/`final`/`substitutionGroup`-style value: `#all` for every diff --git a/Sources/Schema/XSDDerivationTables.swift b/Sources/Schema/XSDDerivationTables.swift index 2510ce5..5cf8c36 100644 --- a/Sources/Schema/XSDDerivationTables.swift +++ b/Sources/Schema/XSDDerivationTables.swift @@ -52,6 +52,18 @@ extension PureXML.Schema.XSDParser { guard let name = XSDDerivNode.attribute(type, "name") else { return } let finalSet = methodSet(XSDDerivNode.attribute(type, "final")) if !finalSet.isEmpty { tables.typeFinal[name] = finalSet } + // A union's named member types: record them so the xsi:type-block check + // can follow a derivation that reaches this union through a member + // (cos-st-derived-OK 2.2.4). Inline (anonymous) members carry no name to + // record and are left out, which keeps the check silent rather than risk + // a false positive. + let union = XSDDerivNode.firstChild(type, named: "union") + if let union, let members = XSDDerivNode.attribute(union, "memberTypes") { + let memberKeys = members.split(whereSeparator: \.isWhitespace).map { + resolvedKey(of: String($0), on: union, default: namespace) + } + if !memberKeys.isEmpty { tables.nsUnionMembers[derivationKey(name, in: namespace)] = memberKeys } + } guard let restriction = XSDDerivNode.firstChild(type, named: "restriction"), let base = XSDDerivNode.attribute(restriction, "base") else { return } tables.typeDerivation[name] = PureXML.Schema.TypeDerivation(base: XSDDerivNode.stripPrefix(base), method: .restriction) diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index 1d2776a..a4570c5 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -212,6 +212,7 @@ extension PureXML.Schema.XSDParser { nsTypeBlock: build.derivation.nsTypeBlock, nsElementBlock: build.derivation.nsElementBlock, nsTypeDerivation: build.derivation.nsTypeDerivation, + nsUnionMembers: build.derivation.nsUnionMembers, typeFinal: build.derivation.typeFinal, targetNamespace: build.context.targetNamespace, schemaErrors: build.context.diagnostics.deduplicated, diff --git a/Sources/Schema/XSDValidations.swift b/Sources/Schema/XSDValidations.swift index f22213e..aec0f16 100644 --- a/Sources/Schema/XSDValidations.swift +++ b/Sources/Schema/XSDValidations.swift @@ -12,6 +12,7 @@ public extension PureXML.Validation { public let typeBlock: [String: Set] public let elementBlock: [String: Set] public let typeDerivation: [String: PureXML.Schema.TypeDerivation] + public let unionMembers: [String: [String]] public let globalAttributes: [String: PureXML.Schema.AttributeUse] public let identityFieldTypes: [String: PureXML.Schema.SimpleType] public let identityFieldConstraints: [String: PureXML.Schema.ValueConstraint] @@ -26,6 +27,7 @@ public extension PureXML.Validation { typeBlock: [String: Set] = [:], elementBlock: [String: Set] = [:], typeDerivation: [String: PureXML.Schema.TypeDerivation] = [:], + unionMembers: [String: [String]] = [:], globalAttributes: [String: PureXML.Schema.AttributeUse] = [:], identityFieldTypes: [String: PureXML.Schema.SimpleType] = [:], identityFieldConstraints: [String: PureXML.Schema.ValueConstraint] = [:], @@ -39,6 +41,7 @@ public extension PureXML.Validation { self.typeBlock = typeBlock self.elementBlock = elementBlock self.typeDerivation = typeDerivation + self.unionMembers = unionMembers self.globalAttributes = globalAttributes self.identityFieldTypes = identityFieldTypes self.identityFieldConstraints = identityFieldConstraints @@ -83,6 +86,7 @@ public extension PureXML.Validation { typeBlock: schema.typeBlock, elementBlock: schema.elementBlock, typeDerivation: schema.typeDerivation, + unionMembers: schema.unionMembers, ) var errors = validator.validate(root, as: declaration, at: [.element(root.name.description)]) // Document-scoped ID uniqueness and IDREF resolution, gathered diff --git a/Tests/SchemaXSITypeUnionMemberBlockTests.swift b/Tests/SchemaXSITypeUnionMemberBlockTests.swift new file mode 100644 index 0000000..bd621c5 --- /dev/null +++ b/Tests/SchemaXSITypeUnionMemberBlockTests.swift @@ -0,0 +1,96 @@ +import Testing +@testable import PureXML + +/// cvc-elt.4.3 with cos-st-derived-OK clause 2.2.4: an `xsi:type` is validly +/// derived from a union declared type when it is validly derived from one of the +/// union's member types. The `{disallowed substitutions}` (`block`) applies to +/// the derivation method used to reach that member, so a substitute that reaches +/// the declared union through a member by a blocked method must be rejected, even +/// though the substitute is not on the union's own base chain. +/// +/// The W3C XSTS instance test `elemT074` is exactly this shape: `block="restriction"` +/// on the element, declared type a union, and an `xsi:type` that restricts a union +/// member. Before the union-member derivation path was followed, the substitution +/// was accepted (a false negative); the block check found no base-chain path to the +/// union and stayed silent. +@Suite("xsi:type block through a union member") +struct SchemaXSITypeUnionMemberBlockTests { + /// `Member` restricts `A`; `A` is a member of the union `U`. The element + /// `e` is declared with the union type and `block` set to `elementBlock`. + private func schema(elementBlock: String) throws -> PureXML.Schema.Document { + try PureXML.Schema.Document(""" + + + + + + + + + + + + + + + + + """) + } + + private let instance = #"1"# + + @Test("block='restriction' bars an xsi:type that restricts a union member") + func test_blockBarsRestrictionThroughMember() throws { + let errors = try schema(elementBlock: "restriction").validate(instance) + #expect(!errors.isEmpty, "the substitution reaches union member A by restriction, which is blocked") + } + + @Test("block='#all' bars it too") + func test_blockAllBarsThroughMember() throws { + #expect(try !schema(elementBlock: "#all").validate(instance).isEmpty) + } + + @Test("block='extension' does NOT bar a restriction-derived member (no false positive)") + func test_extensionBlockDoesNotBar() throws { + // The derivation through the member uses restriction, not extension, so an + // extension-only block leaves the valid substitution accepted. + let errors = try schema(elementBlock: "extension").validate(instance) + #expect(errors.isEmpty, "extension block must not reject a restriction-derived substitution") + } + + @Test("no block accepts the substitution (no false positive)") + func test_noBlockAccepts() throws { + let errors = try schema(elementBlock: "").validate(instance) + #expect(errors.isEmpty, "with no block the union-member substitution is valid") + } + + /// A substitute that IS a union member must be accepted even when it also + /// derives, by a blocked method, from another member listed before it: the + /// union member clause is existential (cos-st-derived-OK 2.2.4), and the + /// self-membership path uses no derivation method, so it is never blocked. + /// Regression for an over-rejection where the first derivable member shadowed + /// the later self-membership match. + @Test("a member that also descends from an earlier member is accepted (no false positive)") + func test_selfMembershipNotShadowedByEarlierMember() throws { + // U's members are P then Q, and Q restricts P. xsi:type="Q" is a member of + // U (valid), though it also reaches member P by the blocked restriction. + let document = try PureXML.Schema.Document(""" + + + + + + + + + + + + + + """) + let instance = #"1"# + #expect(try document.validate(instance).isEmpty, "Q is a member of U; self-membership uses no method and is not blocked") + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index f479391..436a231 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -53,10 +53,14 @@ struct XSTSSuiteTests { /// a defaulted/fixed IDREF/IDREFS must resolve to a matching ID, cvc-id (idZ012). /// 19 -> 17: a negated character-class subtraction excludes both the /// negated base and the subtrahend (`[^cde-[ag]]`), RegexTest_430 and _422. - /// Latest (17 -> 15): the attribute-wildcard union of a type extension is + /// 17 -> 15: the attribute-wildcard union of a type extension is /// computed per Errata E1-10, so the resulting wildcard admits exactly the /// right attributes (wildZ013a, wildZ013d). - private let knownInstanceInvalidAccepted = 15 + /// Latest (15 -> 14): an `xsi:type` that reaches a union declared type through + /// one of its members by a blocked derivation method is rejected (cvc-elt.4.3 + /// / cos-st-derived-OK 2.2.4, elemT074: `block="restriction"` on the element, + /// the substitute restricting a union member). + private let knownInstanceInvalidAccepted = 14 /// Suspect instance tests excluded from the counts: the Ethiopic-digit `\d` /// cases contradict `\d` = `\p{Nd}` at Unicode 3.2 and are tracked as disputed. private static let specDivergentInstances: Set = ["reS17.v", "reS38.v", "reZ004v.v"]