From f9b56077cced9fe94778679131b13b9f3036355f Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 20 Jun 2026 16:22:31 +0200 Subject: [PATCH] fix(schema): streaming xsi:type and type resolution match the tree path The streaming XSD validator resolved element and xsi:type references by bare local name, while its type tables are keyed by namespaced identity ({ns}local). A prefixed xsi:type in an imported namespace missed (a false "unknown xsi:type") or hit a same-local-name type in the wrong namespace, and two global elements sharing a local name across namespaces collided. Resolve through in-scope prefix bindings accumulated down the element stack, keyed by namespaced identity, mirroring the tree path: - the xsi:type override type, the undeclared-type check, and root/child element declarations are namespaced; root resolution mirrors the tree's globalElementDeclaration (bare local-name map only for a no-namespace root). - run the full cvc-elt.4 substitution gate the tree path runs (blocked / abstract / not-validly-derived / list-or-union / anyType-for-anySimpleType override, and an abstract declared type carrying no xsi:type). - resolve a type from an element's own xsi:type where there is no governing declaration: an undeclared root (the Sun target-namespace fallback) and a strict-wildcard child, both via a shared xsiTypeNamedType helper. Streaming now agrees with the tree-path oracle across these, verified by an adversarial streaming-vs-tree differential. No XSTS bucket changes (the tree path is the conformance oracle); gate held at 0/37/0/26. --- CHANGELOG.md | 14 ++ Sources/Schema/ComplexValidator.swift | 12 +- .../Schema/ComplexValidatorStreaming.swift | 22 ++- Sources/Schema/StreamingXSDValidator.swift | 96 ++++++++++--- Tests/SchemaNamespacedDerivationTests.swift | 18 +++ Tests/SchemaStreamingXsiTypeGateTests.swift | 129 ++++++++++++++++++ 6 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 Tests/SchemaStreamingXsiTypeGateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fecb4..4de19e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The streaming XSD validator now agrees with the tree validator on `xsi:type` and + type resolution (#186). Type resolution is keyed by namespaced identity + (`{ns}local`) through in-scope prefix bindings accumulated down the element stack: + the `xsi:type` override type, the undeclared-type check, and root/child element + declarations (so two globals or types sharing a local name in different namespaces + no longer collide; root resolution mirrors the tree path, with the bare local-name + map used only for a no-namespace root). The streaming path now runs the full + cvc-elt.4 gate the tree path runs (a blocked, abstract, not-validly-derived, + list/union, or anyType-for-anySimpleType override, and an abstract declared type + carrying no `xsi:type`, are rejected identically). And it resolves a type from an + element's own `xsi:type` where there is no governing declaration: an undeclared + root (the W3C Sun target-namespace fallback) and a strict-wildcard child, which the + streaming path previously rejected as undeclared. + - An attribute declaration may no longer land in the XSI namespace (XSD 1.0 Schema Component Constraint "xsi: Not Allowed", ยง3.2.6). A schema may target the XSI namespace and declare unqualified attributes (they land in no namespace), but a diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 7288a36..289d7dc 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -26,14 +26,18 @@ public extension PureXML.Schema { private let nillableElements: Set /// The `default`/`fixed` value constraint declared on each element name. private let elementConstraints: [String: ValueConstraint] - /// Local names of complex types declared `abstract="true"`. + /// Complex types declared `abstract="true"`, keyed by namespaced identity + /// (`{ns}local`) so same-local-name types in different namespaces stay distinct. let abstractTypes: Set - /// Derivation methods each named type forbids through `xsi:type`. + /// Derivation methods each named type forbids through `xsi:type`, keyed by + /// namespaced identity (`{ns}local`). let typeBlock: [String: Set] /// Derivation methods each element declaration forbids through `xsi:type` - /// (the `block` on the element, distinct from the block on its type). + /// (the `block` on the element, distinct from the block on its type), keyed by + /// namespaced identity (`{ns}local`). let elementBlock: [String: Set] - /// Each named complex type's base and derivation method. + /// Each named complex type's base and derivation method, keyed by namespaced + /// identity (`{ns}local`). let typeDerivation: [String: TypeDerivation] /// Global attribute declarations for strict/lax `anyAttribute` validation. let globalAttributes: [String: AttributeUse] diff --git a/Sources/Schema/ComplexValidatorStreaming.swift b/Sources/Schema/ComplexValidatorStreaming.swift index 18f428b..f1c580a 100644 --- a/Sources/Schema/ComplexValidatorStreaming.swift +++ b/Sources/Schema/ComplexValidatorStreaming.swift @@ -29,10 +29,16 @@ public extension PureXML.Schema.ComplexValidator { /// The effective element type after an `xsi:type` override and following any /// `typeReference` chain, so the streaming driver resolves a type once per - /// element rather than re-entering the tree walk. - func effectiveType(_ declared: PureXML.Schema.ElementType, of element: PureXML.Model.Element) -> PureXML.Schema.ElementType { + /// element rather than re-entering the tree walk. The `xsi:type` is resolved + /// through its in-scope prefix bindings to a namespaced type key (as the tree + /// path does), so two imported types sharing a local name do not collide. + func effectiveType( + _ declared: PureXML.Schema.ElementType, + of element: PureXML.Model.Element, + namespaceBindings: [String: String] = [:], + ) -> PureXML.Schema.ElementType { var type = declared - if let overriding = Self.xsiTypeName(element), let resolved = types[overriding] { + if let resolved = resolvedXsiType(element, namespaceBindings: namespaceBindings) { type = resolved } // An unknown or circular chain stays a typeReference, which @@ -43,6 +49,16 @@ public extension PureXML.Schema.ComplexValidator { return type } + /// The type an element's `xsi:type` resolves to through its in-scope prefix + /// bindings, or nil when the named type is undeclared. Used by the streaming + /// driver to report an unknown `xsi:type` the way the tree path does. + func resolvedXsiType(_ element: PureXML.Model.Element, namespaceBindings: [String: String]) -> PureXML.Schema.ElementType? { + guard let reference = Self.xsiTypeReference(element, namespaceBindings: namespaceBindings) ?? Self.xsiTypeName(element) else { + return nil + } + return Self.resolveNamedType(reference, in: types) + } + /// The declared element type of a child `name` in `parent`'s content model: a /// named particle member, or a global declaration reached through a matching /// wildcard's `processContents`. Returns nil when the parent has no element diff --git a/Sources/Schema/StreamingXSDValidator.swift b/Sources/Schema/StreamingXSDValidator.swift index 08b8656..3e9027a 100644 --- a/Sources/Schema/StreamingXSDValidator.swift +++ b/Sources/Schema/StreamingXSDValidator.swift @@ -58,21 +58,35 @@ public extension PureXML.Validation { private mutating func open(_ name: PureXML.Model.QualifiedName, _ attributes: [PureXML.Model.Attribute]) { let path = openPath(name) - let declared = resolveDeclared(name, path: path) + let probe = PureXML.Model.Element(name: name, attributes: attributes) + let parentBindings = stack.last?.bindings ?? [:] + let bindings = PureXML.Schema.ComplexValidator.namespaceBindings(for: probe, inherited: parentBindings) + let declared = resolveDeclared(name, path: path, root: probe, bindings: bindings) if !stack.isEmpty { stack[stack.count - 1].childElements.append(PureXML.Model.Element(name: name)) } - let probe = PureXML.Model.Element(name: name, attributes: attributes) - // An xsi:type naming an undeclared type is an error here too, so the - // streaming and tree validators report the same problem. - let overriding = PureXML.Schema.ComplexValidator.xsiTypeName(probe) - if declared != nil, let overriding, validator.types[overriding] == nil { - collected.append(ValidationError(reason: "unknown xsi:type '\(overriding)' on '\(name.localName)'", at: path)) - stack.append(XSDStreamFrame(name: name, attributes: attributes, effective: nil, path: path)) - return + // Run the same cvc-elt.4 gate the tree path runs unconditionally for a + // declared type: a blocked / not-derived / abstract / list-or-union / + // anyType-for-anySimpleType xsi:type override, AND an abstract declared + // type that requires an xsi:type but carries none. Then, only when an + // xsi:type is present, an override naming an undeclared type is reported + // the same way. Both resolve through the element's in-scope prefix + // bindings, so the streaming and tree validators agree on every input. + if let declared { + if let xsiError = validator.xsiTypeOverrideError(declared: declared, child: probe, at: path, namespaceBindings: bindings) { + collected.append(xsiError) + stack.append(XSDStreamFrame(name: name, attributes: attributes, effective: nil, path: path, bindings: bindings)) + return + } + if PureXML.Schema.ComplexValidator.xsiTypeAttributeValue(probe) != nil, validator.resolvedXsiType(probe, namespaceBindings: bindings) == nil { + let overriding = PureXML.Schema.ComplexValidator.xsiTypeName(probe) ?? "" + collected.append(ValidationError(reason: "unknown xsi:type '\(overriding)' on '\(name.localName)'", at: path)) + stack.append(XSDStreamFrame(name: name, attributes: attributes, effective: nil, path: path, bindings: bindings)) + return + } } - let effective = declared.map { validator.effectiveType($0, of: probe) } - stack.append(XSDStreamFrame(name: name, attributes: attributes, effective: effective, path: path)) + let effective = declared.map { validator.effectiveType($0, of: probe, namespaceBindings: bindings) } + stack.append(XSDStreamFrame(name: name, attributes: attributes, effective: effective, path: path, bindings: bindings)) } private mutating func close() { @@ -89,40 +103,83 @@ public extension PureXML.Validation { /// root (with the root-level prolog checks), or the parent content model's /// child type otherwise. Records prolog errors and returns nil when the /// element has no type to validate against. - private mutating func resolveDeclared(_ name: PureXML.Model.QualifiedName, path: [PathKey]) -> PureXML.Schema.ElementType? { + private mutating func resolveDeclared( + _ name: PureXML.Model.QualifiedName, + path: [PathKey], + root: PureXML.Model.Element, + bindings: [String: String], + ) -> PureXML.Schema.ElementType? { guard stack.isEmpty else { guard let parent = stack.last?.effective else { return nil } if let declared = validator.childType(of: parent, child: name) { return declared } + // A strict wildcard with no global declaration is still typed by the + // child's own xsi:type, if present (mirrors the tree path's + // validateWildcardChild); only without one is it undeclared. if let error = validator.strictWildcardError(for: name, in: parent, at: path) { + if let type = xsiTypeNamedType(root, bindings) { + return type + } collected.append(error) } return nil } sawRoot = true - guard let declaration = rootElements[name.localName] - ?? validator.types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] + // Resolve the root exactly as the tree path's globalElementDeclaration does + // (namespaced identity, with the bare map used only for a no-namespace root), + // then fall back to the type an `xsi:type` names when there is no global + // declaration. Sharing the bare fallback across namespaces would bind a + // foreign-namespace root to a same-local-name target-namespace element. + let override = xsiTypeNamedType(root, bindings) + guard let declaration = globalDeclaration(name) ?? override else { collected.append(ValidationError(reason: "no element declaration for '\(name.localName)'", at: path)) return nil } + if override == nil, rootNamespaceMismatch(name) { + collected.append(ValidationError(reason: "root element '\(name.localName)' is not in the schema target namespace '\(targetNamespace ?? "")'", at: path)) + return nil + } if abstractElements.contains(name.localName) { collected.append(ValidationError(reason: "abstract element '\(name.localName)' must not appear in an instance", at: path)) return nil } - if rootNamespaceMismatch(name) { - collected.append(ValidationError(reason: "root element '\(name.localName)' is not in the schema target namespace '\(targetNamespace ?? "")'", at: path)) - return nil - } return declaration } + /// The global element declaration for a qualified root name, mirroring the + /// tree path's `globalElementDeclaration`: a namespaced lookup, with the bare + /// `rootElements` map used only for a no-namespace root (never across + /// namespaces, which would collide same-local-name globals). + private func globalDeclaration(_ name: PureXML.Model.QualifiedName) -> PureXML.Schema.ElementType? { + if let declaration = validator.types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] { + return declaration + } + if name.namespaceURI == nil || name.namespaceURI?.isEmpty == true { + return rootElements[name.localName] + } + return nil + } + private func rootNamespaceMismatch(_ name: PureXML.Model.QualifiedName) -> Bool { guard let target = targetNamespace, !target.isEmpty, name.namespaceURI != target else { return false } return validator.types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] == nil } + /// The type an element's `xsi:type` names, resolved through its in-scope + /// prefix bindings. Used where an element has no governing declaration but a + /// concrete `xsi:type` supplies the type: an undeclared root (the tree path's + /// `SchemaDocument` Sun target-namespace fallback) and a strict-wildcard child + /// with no global declaration (the tree path's `xsiDeclaredType`). + private func xsiTypeNamedType(_ element: PureXML.Model.Element, _ bindings: [String: String]) -> PureXML.Schema.ElementType? { + guard PureXML.Schema.ComplexValidator.xsiTypeAttributeValue(element) != nil else { return nil } + guard let reference = PureXML.Schema.ComplexValidator.xsiTypeReference(element, namespaceBindings: bindings), + let type = PureXML.Schema.ComplexValidator.resolveNamedType(reference, in: validator.types) + else { return nil } + return type + } + /// The coding path for an opening element: the root unindexed, a child with /// a one-based occurrence index (the `xmlGetNodePath` convention). private mutating func openPath(_ name: PureXML.Model.QualifiedName) -> [PathKey] { @@ -144,6 +201,9 @@ private struct XSDStreamFrame { let attributes: [PureXML.Model.Attribute] let effective: PureXML.Schema.ElementType? let path: [PureXML.Validation.PathKey] + /// In-scope prefix bindings at this element (ancestors merged with own `xmlns`), + /// so a descendant's `xsi:type` resolves against prefixes declared up the tree. + var bindings: [String: String] = [:] var childElements: [PureXML.Model.Element] = [] var childCounts: [String: Int] = [:] var text = "" diff --git a/Tests/SchemaNamespacedDerivationTests.swift b/Tests/SchemaNamespacedDerivationTests.swift index 946ae30..e6737bf 100644 --- a/Tests/SchemaNamespacedDerivationTests.swift +++ b/Tests/SchemaNamespacedDerivationTests.swift @@ -58,4 +58,22 @@ struct SchemaNamespacedDerivationTests { """ #expect(try !document.validate(instance, schemaLoader: loader).isEmpty) } + + /// The streaming driver resolves `xsi:type` through its in-scope prefix bindings, + /// not the bare local name (#186). With the namespaced type table keyed by + /// `{ns}local`, a bare `"TD"` lookup misses, so the old streaming path reported a + /// false "unknown xsi:type"; the prefixed `b:TD` must resolve to urn:b's type and + /// agree with the tree path. + @Test("streaming xsi:type resolves by namespace and agrees with the tree path (#186)") + func test_streamingNamespacedXsiType() throws { + let document = try PureXML.Schema.Document(schemaA, schemaLoader: loader) + let valid = """ + ab + """ + let tree = try document.validate(valid, schemaLoader: loader).map(\.reason).sorted() + let streamed = try document.validate(streaming: valid).map(\.reason).sorted() + #expect(tree.isEmpty, "tree rejected a valid namespaced xsi:type: \(tree)") + #expect(streamed == tree, "streaming disagreed with tree: \(streamed)") + } } diff --git a/Tests/SchemaStreamingXsiTypeGateTests.swift b/Tests/SchemaStreamingXsiTypeGateTests.swift new file mode 100644 index 0000000..4362d10 --- /dev/null +++ b/Tests/SchemaStreamingXsiTypeGateTests.swift @@ -0,0 +1,129 @@ +import Testing +@testable import PureXML + +/// The streaming validator must run the same `xsi:type` substitution-validity gate +/// (cvc-elt.4.3) the tree path runs, so it rejects a blocked, abstract, or +/// not-validly-derived override identically (#186). Each case asserts streaming +/// agrees with the tree oracle and that the invalid override is rejected, and a +/// valid override is accepted on both paths. +@Suite("XSD streaming xsi:type substitution gate (#186)") +struct SchemaStreamingXsiTypeGateTests { + private let schema = """ + + + + + + + + + + + + + + + + + + """ + + private func agreement(_ instance: String) throws -> [String] { + let document = try PureXML.Schema.Document(schema) + let tree = try document.validate(instance).map(\.reason).sorted() + let streamed = try document.validate(streaming: instance).map(\.reason).sorted() + #expect(tree == streamed, "streaming disagreed with tree on:\n\(instance)\n tree: \(tree)\n stream: \(streamed)") + return streamed + } + + private func instance(_ element: String, _ type: String, _ body: String) -> String { + """ + \(body) + """ + } + + @Test("a valid derived xsi:type is accepted on both paths") + func test_validOverrideAccepted() throws { + #expect(try agreement(instance("e", "Derived", "ab")).isEmpty) + } + + @Test("a not-validly-derived xsi:type is rejected on both paths (cvc-elt.4.3.2.1)") + func test_notDerivedRejected() throws { + #expect(try !agreement(instance("e", "Unrelated", "a")).isEmpty) + } + + @Test("an abstract xsi:type is rejected on both paths") + func test_abstractRejected() throws { + #expect(try !agreement(instance("e", "Abs", "ab")).isEmpty) + } + + @Test("a blocked xsi:type substitution is rejected on both paths") + func test_blockedRejected() throws { + #expect(try !agreement(instance("b", "BlockedDerived", "ab")).isEmpty) + } + + @Test("an abstract declared type with no xsi:type is rejected on both paths (cvc-elt.4.2)") + func test_abstractDeclaredTypeRequiresXsiType() throws { + let noOverride = """ + a + """ + #expect(try !agreement(noOverride).isEmpty) + } + + @Test("a root with no global declaration but a valid xsi:type is accepted on both paths") + func test_undeclaredRootWithXsiTypeAccepted() throws { + // The root 'undeclared' has no global element declaration; its xsi:type names a + // known type, so both paths validate the body against that type (Sun target-NS + // tests). Streaming must not report a false "no element declaration". + #expect(try agreement(instance("undeclared", "Base", "a")).isEmpty) + } + + @Test("an undeclared root with xsi:type and a wrong body is rejected with the same content error") + func test_undeclaredRootWithXsiTypeWrongBody() throws { + // Both paths must report the content-model error against the xsi:type, not a + // root "no element declaration". + let errors = try agreement(instance("undeclared", "Base", "a")) + #expect(!errors.isEmpty) + #expect(!errors.contains { $0.contains("no element declaration") }, "got: \(errors)") + } + + @Test("a foreign-namespace root sharing a local name with a target-ns element uses its xsi:type, not that element") + func test_foreignNamespaceRootDoesNotBindLocalNameElement() throws { + // 'e' is a target-ns element typed t:Base. The root here is o:e in urn:other: + // it has no global declaration, so its xsi:type='t:Unrelated' (a sibling type + // not derived from Base) names the validating type. The bare-local-name root + // fallback must NOT bind the target-ns 'e' across namespaces (that would gate + // the body against Base and falsely reject the valid Unrelated override). + let valid = """ + a + """ + #expect(try agreement(valid).isEmpty) + } + + @Test("a strict-wildcard child typed only by its xsi:type is accepted on both paths") + func test_strictWildcardChildTypedByXsiType() throws { + // 'item' (urn:o) has no global declaration; a strict ##other wildcard admits it + // and its xsi:type names a known type, so both paths validate it against that + // type rather than reporting "no declaration for wildcard-matched element". + let wildcardSchema = """ + + + + + + + """ + let xml = """ + + hello + + """ + let document = try PureXML.Schema.Document(wildcardSchema) + let tree = try document.validate(xml).map(\.reason).sorted() + let streamed = try document.validate(streaming: xml).map(\.reason).sorted() + #expect(tree == streamed, "tree: \(tree)\nstream: \(streamed)") + #expect(streamed.isEmpty, "streaming rejected a valid wildcard-xsi:type child: \(streamed)") + } +}