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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-29 10:10:04 +02:00 committed by GitHub
parent 0e782737a3
commit 20da41995a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 72 additions and 6 deletions

View File

@ -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.

View File

@ -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

View File

@ -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("""
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="targetNS" targetNamespace="targetNS">
<xsd:complexType name="Test"><xsd:sequence>
<xsd:element name="abc" type="xsd:string"/>
</xsd:sequence></xsd:complexType>
</xsd:schema>
""")
}
@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 = """
<t:test xmlns:t="targetNS" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="Test"><abc>1</abc></t:test>
"""
#expect(try !schema().validate(xml).isEmpty)
}
@Test("a prefixed xsi:type resolves through its prefix's namespace")
func test_prefixedResolves() throws {
let xml = """
<t:test xmlns:t="targetNS" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="t:Test"><abc>1</abc></t:test>
"""
#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("""
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="targetNS" targetNamespace="targetNS">
<xsd:complexType name="T"><xsd:simpleContent>
<xsd:extension base="xsd:string"/>
</xsd:simpleContent></xsd:complexType>
</xsd:schema>
""")
let xml = """
<e xmlns="targetNS" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="T">hello</e>
"""
#expect(try schema.validate(xml).isEmpty)
}
}

View File

@ -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