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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-20 13:31:22 +02:00
parent 2a9a5afaa9
commit 7256df1ceb
5 changed files with 203 additions and 2 deletions

View File

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

View File

@ -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<String>] = [

View File

@ -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<String> = [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<String>,
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
}
}

View File

@ -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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:a="a" xmlns:b="b" xmlns:c="c" xmlns:d="d">
<xs:import namespace="a"/>
<xs:import namespace="b"/>
<xs:import namespace="c"/>
<xs:complexType name="uType">
<xs:sequence>
<xs:element ref="a:a"/>
<xs:element ref="b:b"/>
<xs:element ref="c:c"/>
<xs:element ref="d:d"/>
</xs:sequence>
</xs:complexType>
</xs: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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:a="a" xmlns:b="b" xmlns:c="c">
<xs:import namespace="a"/>
<xs:import namespace="b"/>
<xs:import namespace="c"/>
<xs:complexType name="uType">
<xs:sequence>
<xs:element ref="a:a"/>
<xs:element ref="b:b"/>
<xs:element ref="c:c"/>
</xs:sequence>
</xs:complexType>
</xs: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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
targetNamespace="main" xmlns:m="main" xmlns:a="a" xmlns:b="b">
<xs:import namespace="a" schemaLocation="schZ004a.xsd"/>
<xs:complexType name="ct">
<xs:sequence>
<xs:element name="a" type="a:a"/>
<xs:element name="b" type="b:b"/>
</xs:sequence>
</xs:complexType>
</xs: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 = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:a="a" xmlns:z="z">
<xs:import namespace="a"/>
<xs:element name="e" type="z:t"/>
</xs:schema>
"""
#expect(!compiles(schema), "z:t names an un-imported namespace and must be rejected")
}
}

View File

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