From 7256df1cebbbea34996f075e415d64097b83c21e Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 20 Jun 2026 13:31:22 +0200 Subject: [PATCH] fix(schema): reject references into never-imported namespaces On the cross-document skip path (a schema declares import/include/redefine but no external document was loaded), the reference check stood down whole, so a prefixed reference whose namespace is never imported was accepted even though it can resolve to no component under any loading (corpus schZ011's d:d, where namespaces a/b/c are imported but d is not). Add undeclaredNamespaceReferenceErrors, run only on that skip path, to flag a reference whose namespace is neither the target, the XSD or XML namespace, nor named by any xs:import. To stay sound when the import closure is unknown, it stands down entirely the moment any external carries a schemaLocation: that document may transitively import the referenced namespace (corpus schZ004 imports only a, yet a's document imports b, so b:b resolves). It applies only when every external is location-less and the closure is provably complete. XSTS invalid-schemas-accepted 43 -> 39 (schZ011_a-d); valid-schemas-rejected held at 0; no other bucket moved. --- CHANGELOG.md | 11 +++ Sources/Schema/SchemaReferences.swift | 1 + .../Schema/SchemaUndeclaredNamespace.swift | 96 +++++++++++++++++++ ...SchemaForeignNamespaceReferenceTests.swift | 93 ++++++++++++++++++ Tests/XSTSSuiteTests.swift | 4 +- 5 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 Sources/Schema/SchemaUndeclaredNamespace.swift create mode 100644 Tests/SchemaForeignNamespaceReferenceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2d556..e9af6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A prefixed reference (`type`/`base`/`itemType`/`ref`/`substitutionGroup`/`memberTypes`) + whose namespace is never imported is now rejected on the cross-document skip path + (XSD `src-resolve`). When a schema declares imports but loads no external document, + the pool-based check stands down; a reference into a namespace that is neither the + target, the XSD or XML namespace, nor named by any `xs:import` can resolve to no + component and is flagged. To stay sound when the import closure is unknown, the + check stands down whenever any external carries a `schemaLocation` (an unloaded + located document may transitively import the referenced namespace), applying only + when every external is location-less. XSTS invalid-schemas-accepted 43 -> 39 + (schZ011_a-d), valid-schemas-rejected held at 0, no other bucket moved. + - A non-nillable element may now carry no `xsi:nil` attribute at all, whatever its value (cvc-elt.3.1). Previously only `xsi:nil="true"` on a non-nillable element was rejected; `xsi:nil="false"` slipped through. `ComplexValidator.nilErrors` now diff --git a/Sources/Schema/SchemaReferences.swift b/Sources/Schema/SchemaReferences.swift index 74d7a19..168f765 100644 --- a/Sources/Schema/SchemaReferences.swift +++ b/Sources/Schema/SchemaReferences.swift @@ -60,6 +60,7 @@ extension PureXML.Schema.XSDParser { let simpleContentErrors = simpleContentBaseErrors(schema, in: context) if skipsCrossDocumentRules(schema, compositionLoaded: context.compositionLoaded) { return xsdErrors + simpleContentErrors + + undeclaredNamespaceReferenceErrors(schema, containers: containers) } let types = referenceBuiltins.union(context.simpleTypes.keys).union(context.complexTypeNodes.keys) let pools: [String: Set] = [ diff --git a/Sources/Schema/SchemaUndeclaredNamespace.swift b/Sources/Schema/SchemaUndeclaredNamespace.swift new file mode 100644 index 0000000..c48a555 --- /dev/null +++ b/Sources/Schema/SchemaUndeclaredNamespace.swift @@ -0,0 +1,96 @@ +extension PureXML.Schema.XSDParser { + /// XSD 1.0 `src-resolve`, reduced to the part that holds even when no external + /// document was loaded: a prefixed reference whose namespace is neither this + /// schema's target namespace, the XSD or XML namespace, nor declared by any + /// `xs:import` in the assembled set can resolve to no component, so it is + /// unresolvable regardless. This runs only on the cross-document skip path. + /// + /// Soundness requires that the import closure be fully *known*. It is known only + /// when every `import`/`include`/`redefine` is location-less: there is nothing to + /// load, so no unseen document can transitively import a further namespace. The + /// moment any external carries a `schemaLocation` that did not load, the closure is + /// unknown (that document might import the very namespace a reference names, as in + /// corpus schZ004, where the main doc imports only `a` but `a`'s document imports + /// `b`, making `b:b` resolvable), so the whole check stands down to stay lenient. + static func undeclaredNamespaceReferenceErrors(_ schema: XSDTree, containers: [XSDTree]) -> [String] { + let sources = containers.isEmpty ? [schema] : containers + if hasLocatedExternal(sources) { return [] } + var declared: Set = [xsdNamespace] + declared.formUnion(unloadedReferenceNamespaces) + for container in sources where PureXML.Schema.XSDNode.localName(container) == "schema" { + if let target = PureXML.Schema.XSDNode.attribute(container, "targetNamespace"), !target.isEmpty { + declared.insert(target) + } + for node in descendants(container, named: "import") where node.name?.namespaceURI == xsdNamespace { + if let namespace = PureXML.Schema.XSDNode.attribute(node, "namespace"), !namespace.isEmpty { + declared.insert(namespace) + } + } + } + var errors: [String] = [] + for container in sources where PureXML.Schema.XSDNode.localName(container) == "schema" { + collectUndeclaredNamespaceReferences( + container, + inheritedBindings: PureXML.Schema.XSDNode.namespaceBindings(of: container), + declared: declared, + into: &errors, + ) + } + return errors + } + + private static func collectUndeclaredNamespaceReferences( + _ node: XSDTree, + inheritedBindings: [String: String], + declared: Set, + into errors: inout [String], + ) { + let local = PureXML.Schema.XSDNode.localName(node) + if local == "appinfo" || local == "documentation" { return } + let bindings = mergedNamespaceBindings(on: node, inherited: inheritedBindings) + if node.name?.namespaceURI == xsdNamespace { + for qname in foreignReferenceQNames(of: node) { + guard PureXML.Schema.XSDNode.prefix(qname.trimmingXMLWhitespace()) != nil, + let uri = referenceURI(for: qname, bindings: bindings), !uri.isEmpty, + !declared.contains(uri) + else { continue } + errors.append("reference to '\(qname)' names namespace '\(uri)', which is not the target namespace and is not imported") + } + } + for child in PureXML.Schema.XSDNode.elementChildren(node) { + collectUndeclaredNamespaceReferences(child, inheritedBindings: bindings, declared: declared, into: &errors) + } + } + + /// Whether any `import`/`include`/`redefine` carries a `schemaLocation`. On the + /// skip path nothing was loaded, so a located external is one that failed to load, + /// leaving the import closure (and thus resolvability) unknown. + private static func hasLocatedExternal(_ sources: [XSDTree]) -> Bool { + for container in sources where PureXML.Schema.XSDNode.localName(container) == "schema" { + for kind in ["import", "include", "redefine"] { + for node in descendants(container, named: kind) where node.name?.namespaceURI == xsdNamespace { + if let location = PureXML.Schema.XSDNode.attribute(node, "schemaLocation"), !location.isEmpty { + return true + } + } + } + } + return false + } + + /// Every QName a node names through a resolving attribute: the single-QName + /// `type`/`base`/`itemType`/`ref`/`substitutionGroup`, plus each token of a + /// whitespace-separated `memberTypes`. + private static func foreignReferenceQNames(of node: XSDTree) -> [String] { + var references: [String] = [] + for attribute in ["type", "base", "itemType", "ref", "substitutionGroup"] { + if let qname = PureXML.Schema.XSDNode.attribute(node, attribute) { + references.append(qname) + } + } + if let members = PureXML.Schema.XSDNode.attribute(node, "memberTypes") { + references.append(contentsOf: members.split(whereSeparator: \.isWhitespace).map(String.init)) + } + return references + } +} diff --git a/Tests/SchemaForeignNamespaceReferenceTests.swift b/Tests/SchemaForeignNamespaceReferenceTests.swift new file mode 100644 index 0000000..6e8e391 --- /dev/null +++ b/Tests/SchemaForeignNamespaceReferenceTests.swift @@ -0,0 +1,93 @@ +import Testing +@testable import PureXML + +/// src-resolve on the cross-document skip path (#185): when a schema declares +/// imports but no external document is loaded (no `schemaLocation`, or it does not +/// resolve), the full pool-based resolution stands down. A reference into an +/// *imported* namespace must still stand down (its components may live in the +/// unloaded document), but a reference into a namespace that is never imported can +/// resolve nowhere under any loading and is unresolvable. Mirrors corpus schZ011. +@Suite("XSD foreign-namespace reference resolvability (#185)") +struct SchemaForeignNamespaceReferenceTests { + private func compiles(_ schema: String) -> Bool { + (try? PureXML.Schema.Document(schema)) != nil + } + + @Test("schZ011 shape: a reference into a never-imported namespace is rejected") + func test_unimportedNamespaceReferenceRejected() { + // imports a/b/c (no schemaLocation), references d:d where d is never imported. + let schema = """ + + + + + + + + + + + + + + """ + #expect(!compiles(schema), "d:d names an un-imported namespace and must be rejected") + } + + @Test("lenient guard: references into imported-but-unloaded namespaces stand down") + func test_importedUnloadedNamespaceReferenceAccepted() { + // Same shape without the offending d:d: every foreign reference is into an + // imported namespace whose document was not loaded, so it must not be flagged. + let schema = """ + + + + + + + + + + + + + """ + #expect(compiles(schema), "imported-but-unloaded references must remain lenient") + } + + @Test("schZ004 shape on the skip path: a located (unloaded) import keeps the check lenient") + func test_locatedUnloadedImportStandsDown() { + // Mirrors corpus schZ004 (W3C: valid): the main doc imports only `a` (with a + // schemaLocation), and references `b:b`. Namespace `b` is reachable transitively + // because `a`'s document imports it. When that document does not load, the import + // closure is unknown, so `b:b` must NOT be flagged. Compiled with no loader, so + // the located import fails to resolve and the skip path is taken. + let schema = """ + + + + + + + + + + """ + #expect(compiles(schema), "b:b is transitively resolvable; an unloaded located import must stay lenient") + } + + @Test("a foreign type reference into a never-imported namespace is also rejected") + func test_unimportedTypeReferenceRejected() { + let schema = """ + + + + + """ + #expect(!compiles(schema), "z:t names an un-imported namespace and must be rejected") + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index f7f0873..b14fb25 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -146,9 +146,9 @@ struct XSTSSuiteTests { /// model-group occurrence, identity-constraint content, derivation base kind, /// attribute/simple-type rules, composition, simpleContent inline-type checks, /// complexContent mixedness, and substitution-group anySimpleType checks. - /// Substitution closure barriers and element-ref metadata moved 45 -> 43. + /// Substitution barriers and element-ref metadata moved 45 -> 43; src-resolve unresolvable foreign-ref (#185) 43 -> 39. private let knownSchemaValidRejected = 0 - private let knownSchemaInvalidAccepted = 43 + private let knownSchemaInvalidAccepted = 39 private let knownInstanceValidRejected = 0 /// Instance bucket (133 -> 26), per-step deltas in CHANGELOG.md: xsi:type must derive from the /// declared type (complex/atomic/list/union via the element-ref chain); anyType cannot stand in