From 2b7138ab6147e718c5133b28aee94bd76548f12e Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 14:24:17 +0200 Subject: [PATCH] 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 --- Sources/Schema/ComplexValidator.swift | 4 +- Sources/Schema/ComplexValidatorHelpers.swift | 7 + Sources/Schema/SchemaDocument.swift | 13 ++ Sources/Schema/XSDContext.swift | 12 +- Sources/Schema/XSDDerivation.swift | 33 +++++ Sources/Schema/XSDParser.swift | 37 +++-- Tests/XSDNamespaceTests.swift | 143 +++++++++++++++++++ 7 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 Tests/XSDNamespaceTests.swift diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index cfdf203..122e866 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -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)) } diff --git a/Sources/Schema/ComplexValidatorHelpers.swift b/Sources/Schema/ComplexValidatorHelpers.swift index 06698e9..508d559 100644 --- a/Sources/Schema/ComplexValidatorHelpers.swift +++ b/Sources/Schema/ComplexValidatorHelpers.swift @@ -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 { diff --git a/Sources/Schema/SchemaDocument.swift b/Sources/Schema/SchemaDocument.swift index bb03acc..35acb1f 100644 --- a/Sources/Schema/SchemaDocument.swift +++ b/Sources/Schema/SchemaDocument.swift @@ -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 private let typeBlock: [String: Set] 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) { diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index 045bc7b..86b73f1 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -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]] = [:] diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift index 9f0918b..dd0638f 100644 --- a/Sources/Schema/XSDDerivation.swift +++ b/Sources/Schema/XSDDerivation.swift @@ -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" + } } diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index 911988c..e6e4ee7 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -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. diff --git a/Tests/XSDNamespaceTests.swift b/Tests/XSDNamespaceTests.swift new file mode 100644 index 0000000..bce233a --- /dev/null +++ b/Tests/XSDNamespaceTests.swift @@ -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 = """ + + + + """ + #expect(try validate(xsd, "x").isEmpty) + } + + @Test("A root element not in the target namespace is rejected") + func test_targetNamespaceMismatch() throws { + let xsd = """ + + + + """ + // No namespace on the instance. + #expect(try !validate(xsd, "x").isEmpty) + // Wrong namespace on the instance. + #expect(try !validate(xsd, "x").isEmpty) + } + + @Test("elementFormDefault=qualified requires local children in the target namespace") + func test_elementFormQualified() throws { + let xsd = """ + + + + + + + + + + """ + // The default namespace qualifies both the root and the local child. + #expect(try validate(xsd, "x").isEmpty) + // An unqualified child does not match the qualified declaration. + let mixed = "x" + #expect(try !validate(xsd, mixed).isEmpty) + } + + @Test("elementFormDefault=unqualified keeps local children in no namespace") + func test_elementFormUnqualified() throws { + let xsd = """ + + + + + + + + + + """ + // 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 = "x" + #expect(try validate(xsd, xml).isEmpty) + } + + @Test("attributeFormDefault=qualified requires a qualified attribute") + func test_attributeFormQualified() throws { + let xsd = """ + + + + + + + + """ + // A prefixed (qualified) attribute matches the qualified declaration. + #expect(try validate(xsd, "").isEmpty) + // An unqualified attribute is undeclared under attributeFormDefault=qualified. + #expect(try !validate(xsd, "").isEmpty) + } + + @Test("xs:all with maxOccurs>1 on a member is a schema error") + func test_allMemberMaxOccurs() throws { + let xsd = """ + + + + + + + + + + """ + #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 = """ + + + + + + + + + + + + """ + #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 = """ + + + + + + + + + + + """ + #expect(try validate(xsd, "21").isEmpty) + } +}