feat(schema): XSD namespace qualification and xs:all constraints

Make XSD validation namespace-aware and enforce the form defaults and
xs:all structural constraints that were previously ignored:

- targetNamespace is tracked: a schema's global elements are in its
  target namespace, and an instance's root element must be in it.
- elementFormDefault / attributeFormDefault (and per-declaration form)
  qualify local element and attribute declarations. The content-model
  matcher already compares namespace URIs, so qualified declarations
  now match only same-namespace instance nodes; attribute matching
  became namespace-aware too.
- xs:all groups are checked at compile time: every member must be an
  element occurring at most once, the group may occur at most once, and
  nested model groups inside an all are rejected (a SchemaError).

When a schema has no target namespace the stamping is a no-op, so
unqualified schemas validate exactly as before.

Cross-namespace type references through xs:import resolve by local name
only; full multi-namespace type tables are not modeled.

Closes #78
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 14:24:17 +02:00
parent 23b7c081fc
commit 2b7138ab61
7 changed files with 238 additions and 11 deletions

View File

@ -107,7 +107,7 @@ public extension PureXML.Schema {
) {
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) }
for use in type.attributes {
let match = present.first { $0.name.localName == use.name.localName }
let match = present.first { Self.sameName($0.name, use.name) }
if let match {
if let error = use.type.validate(match.value) {
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path))
@ -121,7 +121,7 @@ public extension PureXML.Schema {
}
// An undeclared attribute is allowed only if an xs:anyAttribute wildcard
// admits its namespace.
for attribute in present where !type.attributes.contains(where: { $0.name.localName == attribute.name.localName }) {
for attribute in present where !type.attributes.contains(where: { Self.sameName($0.name, attribute.name) }) {
if type.attributeWildcard?.admits(attribute.name) == true { continue }
errors.append(XSDFailure(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
}

View File

@ -89,6 +89,13 @@ extension PureXML.Schema.ComplexValidator {
"{\(name.namespaceURI ?? "")}\(name.localName)"
}
/// Whether two names match by namespace and local name. Used to match an
/// instance attribute against a declared attribute use, so attributes that
/// share a local name in different namespaces are kept distinct.
static func sameName(_ lhs: PureXML.Model.QualifiedName, _ rhs: PureXML.Model.QualifiedName) -> Bool {
lhs.namespaceURI == rhs.namespaceURI && lhs.localName == rhs.localName
}
static func textContent(_ element: PureXML.Model.Element) -> String {
let text = element.children.reduce(into: "") { result, child in
switch child {

View File

@ -7,6 +7,10 @@ public extension PureXML.Schema {
/// A type inside `xs:redefine` does not redefine itself: its restriction or
/// extension base must name the type it redefines.
case redefineIncompatible(type: String)
/// An `xs:all` group violates its XSD 1.0 constraints: every member must be
/// an element with `maxOccurs` at most 1, and the group itself may occur at
/// most once.
case invalidAllGroup(reason: String)
public var description: String {
switch self {
@ -16,6 +20,8 @@ public extension PureXML.Schema {
"type '\(type)' derives from '\(base)' by \(method), which '\(base)' declares final"
case let .redefineIncompatible(type):
"redefined type '\(type)' must derive from itself"
case let .invalidAllGroup(reason):
"invalid xs:all group: \(reason)"
}
}
}
@ -32,6 +38,7 @@ public extension PureXML.Schema {
private let abstractElements: Set<String>
private let typeBlock: [String: Set<DerivationMethod>]
private let typeDerivation: [String: TypeDerivation]
private let targetNamespace: String?
/// Compiles a schema document. `schemaLoader` resolves the
/// `schemaLocation` of `xs:include`, `xs:import`, and `xs:redefine` to
@ -49,6 +56,7 @@ public extension PureXML.Schema {
abstractElements = compiled.abstractElements
typeBlock = compiled.typeBlock
typeDerivation = compiled.typeDerivation
targetNamespace = compiled.targetNamespace
}
/// Validates an instance document against the schema, returning one located
@ -67,6 +75,11 @@ public extension PureXML.Schema {
guard let declaration = elements[root.name.localName] else {
return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [])]
}
// A schema with a target namespace declares its global elements in that
// namespace; the root must be in it.
if let target = targetNamespace, !target.isEmpty, root.name.namespaceURI != target {
return [.init(reason: "root element '\(root.name.localName)' is not in the schema target namespace '\(target)'", at: [.element(root.name.description)])]
}
// An abstract element may not appear in an instance directly; only a
// concrete substitution-group member may stand in its place.
if abstractElements.contains(root.name.localName) {

View File

@ -116,6 +116,9 @@ extension PureXML.Schema {
/// Each named complex type's base type and derivation method, the backbone
/// the `block` check walks from an `xsi:type` to its declared type.
var typeDerivation: [String: TypeDerivation] = [:]
/// The schema's target namespace, so the root element's namespace can be
/// checked against it.
var targetNamespace: String?
}
/// The parsing context: the named simple types resolved so far, plus the
@ -129,8 +132,15 @@ extension PureXML.Schema {
/// resolve and compose its base type's content model and attributes.
var complexTypeNodes: [String: XSDTree] = [:]
/// The schema's target namespace, for resolving `##other`/`##targetNamespace`
/// in wildcard constraints.
/// in wildcard constraints and qualifying global and qualified-form local
/// declarations.
var targetNamespace: String?
/// Whether `elementFormDefault="qualified"`: local element declarations are
/// in the target namespace unless their own `form` overrides it.
var elementFormQualified: Bool = false
/// Whether `attributeFormDefault="qualified"`: local attribute declarations
/// are in the target namespace unless their own `form` overrides it.
var attributeFormQualified: Bool = false
/// Each substitution-group head maps to its transitive member element
/// names, so an `xs:element ref` to a head also admits its members.
var substitutions: [String: [String]] = [:]

View File

@ -165,4 +165,37 @@ extension PureXML.Schema.XSDParser {
case .substitution: "substitution"
}
}
/// Throws when any `xs:all` group violates its XSD 1.0 constraints: its members
/// must all be elements, each occurring at most once, and the group itself may
/// occur at most once. Nested model groups inside an `all` are not allowed.
static func checkAllGroups(_ containers: [XSDTree]) throws {
for container in containers {
for all in descendants(container, named: "all") {
try checkAllGroup(all)
}
}
}
private static func checkAllGroup(_ all: XSDTree) throws {
if !atMostOnce(XSDDerivNode.attribute(all, "maxOccurs")) {
throw SchemaFault.invalidAllGroup(reason: "the group's maxOccurs must be 0 or 1")
}
for member in XSDDerivNode.elementChildren(all) {
let kind = XSDDerivNode.localName(member) ?? ""
if kind == "annotation" { continue }
guard kind == "element" else {
throw SchemaFault.invalidAllGroup(reason: "a member may only be an element, not '\(kind)'")
}
if !atMostOnce(XSDDerivNode.attribute(member, "maxOccurs")) {
let label = XSDDerivNode.attribute(member, "name") ?? XSDDerivNode.attribute(member, "ref") ?? ""
throw SchemaFault.invalidAllGroup(reason: "element '\(label)' must have maxOccurs 0 or 1")
}
}
}
private static func atMostOnce(_ maxOccurs: String?) -> Bool {
guard let maxOccurs else { return true }
return maxOccurs == "0" || maxOccurs == "1"
}
}

View File

@ -17,6 +17,7 @@ extension PureXML.Schema {
let derivation = derivationTables(containers)
try checkFinal(derivation)
try checkRedefine(containers)
try checkAllGroups(containers)
var context = XSDContext(
simpleTypes: [:],
attributeGroups: indexByName(allChildren(containers, named: "attributeGroup")),
@ -25,6 +26,8 @@ extension PureXML.Schema {
substitutions: filterSubstitutions(XSDNode.substitutionMembers(containers), derivation),
abstractElements: derivation.abstractElements,
)
context.elementFormQualified = XSDNode.attribute(schema, "elementFormDefault") == "qualified"
context.attributeFormQualified = XSDNode.attribute(schema, "attributeFormDefault") == "qualified"
context.complexTypeNodes = indexByName(allChildren(containers, named: "complexType"))
resolveSimpleTypes(allChildren(containers, named: "simpleType"), into: &context)
var types: [String: ElementType] = context.simpleTypes.mapValues(ElementType.simple)
@ -55,6 +58,7 @@ extension PureXML.Schema {
abstractElements: derivation.abstractElements,
typeBlock: derivation.typeBlock,
typeDerivation: derivation.typeDerivation,
targetNamespace: context.targetNamespace,
)
}
@ -186,8 +190,10 @@ extension PureXML.Schema {
} else {
SimpleType(base: .string)
}
let qualified = XSDNode.attribute(node, "form") == "qualified"
|| (XSDNode.attribute(node, "form") == nil && context.attributeFormQualified)
return AttributeUse(
name: PureXML.Model.QualifiedName(name),
name: PureXML.Model.QualifiedName(localName: name, namespaceURI: qualified ? context.targetNamespace : nil),
type: type,
required: required,
valueConstraint: valueConstraint(of: node),
@ -261,26 +267,22 @@ extension PureXML.Schema {
let head = context.abstractElements.contains(name) ? [] : [name]
let alternatives = head + (context.substitutions[name] ?? [])
if alternatives.count == 1 {
return Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(alternatives[0]))
return Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(alternatives[0], context))
}
// The reference admits its substitution-group members, so expand it
// to a choice over the head and every member, each carrying its own
// declared type.
let members = alternatives.map { Particle(term: elementReferenceTerm($0)) }
let members = alternatives.map { Particle(term: elementReferenceTerm($0, context)) }
return Particle(minOccurs: minimum, maxOccurs: maximum, term: .group(Group(compositor: .choice, particles: members)))
}
let name = XSDNode.attribute(node, "name") ?? ""
return Particle(
minOccurs: minimum,
maxOccurs: maximum,
term: .element(name: PureXML.Model.QualifiedName(name), type: elementType(node, context)),
term: .element(name: localElementName(name, XSDNode.attribute(node, "form"), context), type: elementType(node, context)),
)
}
private static func elementReferenceTerm(_ name: String) -> Term {
.element(name: PureXML.Model.QualifiedName(name), type: .typeReference(elementKey(name)))
}
private static func groupReferenceParticle(_ node: XSDTree, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle? {
guard let ref = XSDNode.attribute(node, "ref") else { return nil }
let name = XSDNode.stripPrefix(ref)
@ -294,6 +296,25 @@ extension PureXML.Schema {
}
}
extension PureXML.Schema.XSDParser {
/// The qualified name of a reference to a global element: globals are always in
/// the schema's target namespace.
static func elementReferenceTerm(_ name: String, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.Term {
.element(
name: PureXML.Model.QualifiedName(localName: name, namespaceURI: context.targetNamespace),
type: .typeReference(elementKey(name)),
)
}
/// The qualified name of a local element declaration: in the target namespace
/// when `elementFormDefault` (or the element's own `form`) is qualified,
/// otherwise in no namespace.
static func localElementName(_ name: String, _ form: String?, _ context: PureXML.Schema.XSDContext) -> PureXML.Model.QualifiedName {
let qualified = form == "qualified" || (form == nil && context.elementFormQualified)
return PureXML.Model.QualifiedName(localName: name, namespaceURI: qualified ? context.targetNamespace : nil)
}
}
extension PureXML.Schema.XSDParser {
/// Gathers `nillable` and `default`/`fixed` value constraints from every
/// element declaration at any depth, keyed by the element's name.

View File

@ -0,0 +1,143 @@
@testable import PureXML
import Testing
@Suite("XSD namespace qualification")
struct XSDNamespaceTests {
private func validate(_ xsd: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
try PureXML.Schema.Document(xsd).validate(xml)
}
@Test("A root element in the schema target namespace validates")
func test_targetNamespaceRoot() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:books">
<xs:element name="book" type="xs:string"/>
</xs:schema>
"""
#expect(try validate(xsd, "<book xmlns=\"urn:books\">x</book>").isEmpty)
}
@Test("A root element not in the target namespace is rejected")
func test_targetNamespaceMismatch() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:books">
<xs:element name="book" type="xs:string"/>
</xs:schema>
"""
// No namespace on the instance.
#expect(try !validate(xsd, "<book>x</book>").isEmpty)
// Wrong namespace on the instance.
#expect(try !validate(xsd, "<book xmlns=\"urn:other\">x</book>").isEmpty)
}
@Test("elementFormDefault=qualified requires local children in the target namespace")
func test_elementFormQualified() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:lib" elementFormDefault="qualified">
<xs:element name="book">
<xs:complexType>
<xs:sequence>
<xs:element name="title" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
"""
// The default namespace qualifies both the root and the local child.
#expect(try validate(xsd, "<book xmlns=\"urn:lib\"><title>x</title></book>").isEmpty)
// An unqualified child does not match the qualified declaration.
let mixed = "<book xmlns:l=\"urn:lib\" xmlns=\"urn:lib\"><title xmlns=\"\">x</title></book>"
#expect(try !validate(xsd, mixed).isEmpty)
}
@Test("elementFormDefault=unqualified keeps local children in no namespace")
func test_elementFormUnqualified() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:lib">
<xs:element name="book">
<xs:complexType>
<xs:sequence>
<xs:element name="title" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
"""
// The root is qualified; the local child is unqualified, so it must carry no
// namespace (the prefixed root keeps the default namespace off the child).
let xml = "<l:book xmlns:l=\"urn:lib\"><title>x</title></l:book>"
#expect(try validate(xsd, xml).isEmpty)
}
@Test("attributeFormDefault=qualified requires a qualified attribute")
func test_attributeFormQualified() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:lib" elementFormDefault="qualified" attributeFormDefault="qualified">
<xs:element name="book">
<xs:complexType>
<xs:attribute name="id" type="xs:string"/>
</xs:complexType>
</xs:element>
</xs:schema>
"""
// A prefixed (qualified) attribute matches the qualified declaration.
#expect(try validate(xsd, "<l:book xmlns:l=\"urn:lib\" l:id=\"1\"/>").isEmpty)
// An unqualified attribute is undeclared under attributeFormDefault=qualified.
#expect(try !validate(xsd, "<l:book xmlns:l=\"urn:lib\" id=\"1\"/>").isEmpty)
}
@Test("xs:all with maxOccurs>1 on a member is a schema error")
func test_allMemberMaxOccurs() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="rec">
<xs:complexType>
<xs:all>
<xs:element name="a" type="xs:string" maxOccurs="2"/>
</xs:all>
</xs:complexType>
</xs:element>
</xs:schema>
"""
#expect(throws: PureXML.Schema.SchemaError.self) {
_ = try PureXML.Schema.Document(xsd)
}
}
@Test("xs:all containing a nested model group is a schema error")
func test_allNestedGroup() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="rec">
<xs:complexType>
<xs:all>
<xs:sequence>
<xs:element name="a" type="xs:string"/>
</xs:sequence>
</xs:all>
</xs:complexType>
</xs:element>
</xs:schema>
"""
#expect(throws: PureXML.Schema.SchemaError.self) {
_ = try PureXML.Schema.Document(xsd)
}
}
@Test("A valid xs:all still compiles and validates order-independently")
func test_allValid() throws {
let xsd = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="rec">
<xs:complexType>
<xs:all>
<xs:element name="a" type="xs:string"/>
<xs:element name="b" type="xs:string"/>
</xs:all>
</xs:complexType>
</xs:element>
</xs:schema>
"""
#expect(try validate(xsd, "<rec><b>2</b><a>1</a></rec>").isEmpty)
}
}