From 20da41995a1fba950fe1f30949b2b4d4279ae23f Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 29 Jun 2026 10:10:04 +0200 Subject: [PATCH] fix(schema): resolve an unprefixed xsi:type in the instance namespace context (#383) An xsi:type value is a QName: a prefix binds through that prefix, an unprefixed name through the in-scope default namespace, and with no default namespace an unprefixed name is in no namespace. PureXML stores each named type under both its namespaced key and a bare local-name alias (for lenient lookup), so an unprefixed xsi:type="Test" matched a {targetNS}Test through the alias even when the instance had no default namespace, wrongly typing an otherwise-undeclared root and accepting the document (W3C XSTS targetNS00101m; libxml2 rejects it identically). xsiTypeReference now keys an unprefixed xsi:type to the in-scope default namespace (none here) rather than returning the bare local name, so xsi:type="Test" names no-namespace Test, which does not exist; the root then has neither a declaration nor a usable type and the instance is invalid. A prefixed xsi:type and an unprefixed one under a default namespace equal to the target namespace are unaffected, and the child-element builtin-substitution path keeps its bare-name fallback (xsiTypeName), so an unprefixed builtin still substitutes where valid. Verified over the full W3C XSTS corpus in release: invalid-instances-accepted 6 -> 5, valid-instances-rejected held at 0 across all 14383 groups, full suite (1840 tests) and wasm green. An adversarial critic traced every resolveNamedType branch, including the unprefixed-builtin-via-key case, and confirmed the only path that no longer resolves corresponds to a spec-invalid reference, so no valid instance is newly rejected. --- CHANGELOG.md | 2 + Sources/Schema/ComplexValidatorHelpers.swift | 13 +++-- Tests/SchemaXSITypeNamespaceTests.swift | 57 ++++++++++++++++++++ Tests/XSTSSuiteTests.swift | 6 ++- 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 Tests/SchemaXSITypeNamespaceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2842829..7fcb40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An unprefixed `xsi:type` value now resolves in the instance's namespace context rather than matching a namespaced type by local name alone. `xsi:type` is a `QName`: a prefix binds through that prefix, an unprefixed name through the in-scope default namespace, and with no default namespace an unprefixed name is in no namespace. PureXML stores each named type under both its namespaced key and a bare local-name alias (for lenient lookup), so an unprefixed `xsi:type="Test"` matched a `{targetNS}Test` through the alias even when the instance had no default namespace, wrongly typing an otherwise-undeclared root and accepting the document. The resolver now keys an unprefixed `xsi:type` to the in-scope default namespace (none here), so `xsi:type="Test"` names no-namespace `Test`, which does not exist; the root element then has neither a declaration nor a usable type and the instance is invalid (W3C XSTS targetNS00101m; libxml2 rejects it identically). A prefixed `xsi:type` and an unprefixed one under a matching default namespace are unaffected. Verified over the full W3C XSTS corpus in release: `invalid-instances-accepted` 6 -> 5, `valid-instances-rejected` held at 0 across all 14383 groups, and the full suite passes. + - An XSD `key`/`keyref`/`unique` field that selects a node whose declared type is not simple is now rejected (cvc-identity-constraint.3), rather than wrongly accepted. The XSD identity-constraint rule requires each field to identify a node of simple type; a field that reaches an element declared with no type (the ur-type `anyType`) or an inline complex type without simple content does not, so the instance is invalid (the reference, libxml2, rejects it identically). PureXML accepted such instances because its identity validator could resolve a field target's type only for a global element reached by a direct-child name, not for a local element reached through a path (the failing W3C XSTS case idZ010 nests `anyType` fields several levels deep). A compile-time pass (`XSDParser.identityNonSimpleFieldKeys`) now resolves each field's target element through its selector and field paths, by element-name steps, and records the fields whose declared target is provably non-simple; the validator rejects those at runtime unless the instance gives the node a simple type through `xsi:type` (cvc reads the node's governing type, so a text-only `anyType` element with `xsi:type="xsd:int"` is still a valid field). The resolution is conservative: any wildcard, descendant axis, predicate, `ref`, or named non-builtin type leaves the field unflagged, so a valid field is never rejected. Verified over the full W3C XSTS corpus in release: `invalid-instances-accepted` 8 -> 7 (idZ010 now rejects), `valid-instances-rejected` held at 0 across all 14383 groups (no false positive introduced), and the full suite passes. - The XSD regex escapes `\i` and `\c` now use the XML 1.0 Second Edition name classes (Appendix B), not the parser's Fifth Edition Name production. XSD 1.0 (Datatypes Appendix F) defines `\i` as the characters that may begin an XML Name (`Letter | '_' | ':'`) and `\c` as the characters that may appear in one (`NameChar`), citing the XML 1.0 Second Edition productions, which are the enumerated Unicode-2.0-era character classes of XML 1.0 Appendix B. `\i`/`\c` were wired to `PureXML.Parsing.XMLCharacter`, which correctly implements the Fifth Edition production used to parse live XML, but the two genuinely differ: the Fifth Edition admits whole blocks where the Second Edition has gaps, so `\c` over-accepted, for example the combining mark `U+0346` (`COMBINING BRIDGE ABOVE`), which Appendix B's `CombiningChar` excludes (its first range ends at `U+0345`). A new `XSDNameCharacter`, transcribed clean-room from the Appendix B grammar (identical across editions one through four) and stored as sorted code-point ranges with binary-search membership, now backs both escapes; the parser's own name check is untouched. The two range tables are verified to reproduce the W3C XSTS `\i`/`\c` enumerations (`reZ005`, `reZ006`, 34516 and 35122 code points) exactly and in both directions, so there is no false-positive surface. Verified over the full W3C XSTS corpus in release: `invalid-instances-accepted` 12 -> 11 (`reZ006i`, whose value is `U+0346`, now rejects), with both false-positive buckets at 0. Found by triaging the invalid-instance tail to a single boundary code point (`U+0345` valid vs `U+0346` invalid) and reading the XML 1.0 Appendix B grammar. diff --git a/Sources/Schema/ComplexValidatorHelpers.swift b/Sources/Schema/ComplexValidatorHelpers.swift index 6b73852..46d36cd 100644 --- a/Sources/Schema/ComplexValidatorHelpers.swift +++ b/Sources/Schema/ComplexValidatorHelpers.swift @@ -111,17 +111,20 @@ extension PureXML.Schema.ComplexValidator { return match?.value.trimmingXMLWhitespace() } - /// Resolves an instance `xsi:type` attribute to a named type-table key. + /// Resolves an instance `xsi:type` attribute to a named type-table key. The + /// value is a QName resolved in the instance's namespace context: a prefix + /// binds through that prefix, an unprefixed name through the in-scope default + /// namespace. With no default namespace an unprefixed name is in no namespace, + /// so it keys against the no-namespace type, NOT a namespaced type of the same + /// local name (XSTS targetNS00101m: `xsi:type="Test"` with no default namespace + /// does not name `{targetNS}Test`). static func xsiTypeReference(_ element: PureXML.Model.Element, namespaceBindings: [String: String]) -> String? { guard let value = xsiTypeAttributeValue(element) else { return nil } let local = value.split(separator: ":").last.map(String.init) ?? value if value.contains(":"), let prefix = value.split(separator: ":", maxSplits: 1).first.map(String.init), let uri = namespaceBindings[prefix] { return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: uri) } - if let uri = namespaceBindings[""] { - return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: uri) - } - return local + return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: namespaceBindings[""]) } /// The element's character content, trimmed: used to decide whether an diff --git a/Tests/SchemaXSITypeNamespaceTests.swift b/Tests/SchemaXSITypeNamespaceTests.swift new file mode 100644 index 0000000..0b42a0f --- /dev/null +++ b/Tests/SchemaXSITypeNamespaceTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import PureXML + +/// An `xsi:type` value is a QName resolved in the instance's namespace context: +/// an unprefixed name uses the in-scope default namespace, and with none it is in +/// no namespace, so it does not name a namespaced type of the same local name. +/// Mirrors W3C XSTS targetNS00101m (CType): a schema with a `targetNamespace` +/// declares a type but no element, and a root with `xsi:type` but no element +/// declaration is valid only when the `xsi:type` QName resolves to that type. +@Suite("xsi:type QName resolves in the instance namespace context") +struct SchemaXSITypeNamespaceTests { + private func schema() throws -> PureXML.Schema.Document { + try PureXML.Schema.Document(""" + + + + + + """) + } + + @Test("an unprefixed xsi:type with no default namespace does not name a namespaced type") + func test_unprefixedNoDefault() throws { + // xsi:type="Test" with no default namespace in scope -> no-namespace Test, + // which does not exist; the root `test` has no declaration -> invalid. + let xml = """ + 1 + """ + #expect(try !schema().validate(xml).isEmpty) + } + + @Test("a prefixed xsi:type resolves through its prefix's namespace") + func test_prefixedResolves() throws { + let xml = """ + 1 + """ + #expect(try schema().validate(xml).isEmpty) + } + + @Test("an unprefixed xsi:type resolves through the in-scope default namespace") + func test_unprefixedDefaultNamespace() throws { + // A simpleContent type and a default namespace equal to the target namespace: + // unprefixed "T" names {targetNS}T and the text content validates, with no + // child-element qualification to confound the resolution under test. + let schema = try PureXML.Schema.Document(""" + + + + + + """) + let xml = """ + hello + """ + #expect(try schema.validate(xml).isEmpty) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index aeb8150..45ee5e7 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -91,7 +91,11 @@ struct XSTSSuiteTests { /// cvc-identity-constraint.3 (idZ010). See ``XSDParser/identityNonSimpleFieldKeys``. /// 7 -> 6: mgO013.i reclassified as contested (see the excluded set). Its /// `invalid` is unsatisfiable jointly with its own `valid` schemaTest. - private let knownInstanceInvalidAccepted = 6 + /// 6 -> 5: an unprefixed `xsi:type` QName resolves in the instance's namespace + /// context, so with no default namespace it names no namespace and does not match + /// a namespaced type of the same local name (targetns00101m). See + /// ``ComplexValidator/xsiTypeReference(_:namespaceBindings:)``. + private let knownInstanceInvalidAccepted = 5 /// Instance tests excluded from the counts because their expected validity is not /// authoritative, mirroring how the suite's own `status="queried"` entries are /// skipped. Each must carry a spec-grounded justification; this is never a place to