PureXML/Sources/Schema/XSDParser.swift
Mihaela Mihaljevic 8f2725b62a
fix(schema): reject an identity-constraint field that selects a non-simple node (#381)
XSD 1.0 §3.11.4 (cvc-identity-constraint.3) requires each key/keyref/unique
field to identify a node of simple type. A field that reaches an element
declared with no type (the ur-type anyType) or an inline complex type without
simple content does not, so the instance is invalid; the reference (libxml2)
rejects it identically. PureXML accepted such instances because its identity
validator could resolve a field target's type only for a global element reached
by a direct-child name, not for a local element reached through a path (the W3C
XSTS case idZ010 nests anyType fields several levels deep, e.g. a key field
./b/c where c has no type).

A compile-time pass (XSDParser.identityNonSimpleFieldKeys) resolves each field's
target element through its selector and field paths, by element-name steps, and
records the fields whose declared target is provably non-simple. The validator
rejects those at runtime unless the instance gives the node a simple type
through xsi:type, since cvc reads the node's governing type (a text-only anyType
element with xsi:type="xsd:int" is a valid field). The resolution is
conservative: any wildcard, descendant axis, predicate, ref, or named
non-builtin type leaves the field unflagged, so a valid field is never rejected.
The one residual divergence path, two same-name child elements of differing
simplicity in one inline model, is an Element-Declarations-Consistent violation
the compiler already rejects.

Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
8 -> 7 (idZ010 now rejects), valid-instances-rejected held at 0 across all 14383
groups (no false positive), full suite (1837 tests), wasm, and lint all green.
An adversarial critic that read every file and probed for a false positive could
not construct one; the failure mode is under-rejection, the safe direction.
2026-06-29 09:08:25 +02:00

401 lines
21 KiB
Swift

private typealias XSDNode = PureXML.Schema.XSDNode
private typealias XSDContext = PureXML.Schema.XSDContext
private typealias Particle = PureXML.Schema.Particle
private typealias ComplexType = PureXML.Schema.ComplexType
private typealias ValueConstraint = PureXML.Schema.ValueConstraint
private typealias Compositor = PureXML.Schema.Compositor
private typealias ElementType = PureXML.Schema.ElementType
private typealias XSDSimpleParser = PureXML.Schema.XSDSimpleParser
private typealias BuiltinType = PureXML.Schema.BuiltinType
private typealias Wildcard = PureXML.Schema.Wildcard
private typealias Facets = PureXML.Schema.Facets
private typealias Group = PureXML.Schema.Group
private typealias SimpleType = PureXML.Schema.SimpleType
private typealias AttributeUse = PureXML.Schema.AttributeUse
private typealias XSDCompiled = PureXML.Schema.XSDCompiled
extension PureXML.Schema {
/// Parses an XSD schema document into its global element declarations and its
/// named-type table, matching the schema vocabulary by local name (so the XML
/// Schema namespace prefix may be anything). Covers elements, simple and complex
/// types, model groups, attribute uses, facets, `list`/`union`, and references.
enum XSDParser {
static func parse(_ xsd: String, loader: (String) -> String? = { _ in nil }) throws -> XSDCompiled {
let root = try PureXML.parseTree(xsd)
guard let schema = XSDNode.elementChildren(root).first(where: { XSDNode.localName($0) == "schema" }) else {
throw PureXML.Schema.SchemaError.notASchema
}
var visited: Set<String> = []
var rootLocation: String?
let wrappedLoader: (String) -> String? = { location in
let content = loader(location)
if content == xsd {
rootLocation = location
}
return content
}
let containerTuples = XSDNode.collectContainers(schema, wrappedLoader, &visited)
var locationCheckVisited: Set<String> = []
let failedSchemaReferences = XSDNode.failedSchemaReferences(schema, wrappedLoader, &locationCheckVisited)
let containers = containerTuples.map(\.tree)
let derivation = derivationTables(containers, mainTargetNamespace: XSDNode.attribute(schema, "targetNamespace"))
let containerLocations = containerLocationMap(containerTuples, rootLocation: rootLocation)
let compositionLoaded = XSDNode.compositionLoaded(from: containerTuples)
var context = createContext(
schema: schema,
containers: containers,
derivation: derivation,
containerLocations: containerLocations,
compositionLoaded: compositionLoaded,
)
context.failedSchemaReferences = failedSchemaReferences
return finishCompile(schema: schema, containers: containers, derivation: derivation, context: &context)
}
}
}
extension PureXML.Schema.XSDParser {
static func containerLocationMap(
_ containerTuples: [(location: String?, tree: XSDTree)],
rootLocation: String?,
) -> [ObjectIdentifier: String?] {
var containerLocations: [ObjectIdentifier: String?] = [:]
for (loc, tree) in containerTuples {
// The map value is itself `String?`, so the explicit annotation keeps
// `??` at the `String` level; without it the fallback binds at the
// outer optional and the compile root (no location) maps to nil.
let location: String? = loc ?? rootLocation
containerLocations[ObjectIdentifier(tree)] = location
}
return containerLocations
}
static func createContext(
schema: XSDTree,
containers: [XSDTree],
derivation: DerivationTables,
containerLocations: [ObjectIdentifier: String?],
compositionLoaded: Bool,
) -> PureXML.Schema.XSDContext {
let mainTargetNamespace = XSDNode.attribute(schema, "targetNamespace")
let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: mainTargetNamespace)
var context = XSDContext(
simpleTypes: [:],
attributeGroups: indexByName(allChildren(containers, named: "attributeGroup")),
groups: indexByName(allChildren(containers, named: "group")),
targetNamespace: mainTargetNamespace,
substitutions: filterSubstitutions(
XSDNode.substitutionMembers(
containers,
namespaceMap: namespaceMap,
mainTargetNamespace: mainTargetNamespace,
chainBlockedBySubstitution: Set(derivation.nsElementBlock.compactMap { element, block in
block.contains(.substitution) ? element : nil
}),
),
derivation,
),
abstractElements: derivation.abstractElements,
compositionLoaded: compositionLoaded,
containerLocations: containerLocations,
)
context.blockDefault = XSDNode.attribute(schema, "blockDefault")
context.elementFormQualified = XSDNode.attribute(schema, "elementFormDefault") == "qualified"
context.attributeFormQualified = XSDNode.attribute(schema, "attributeFormDefault") == "qualified"
context.namespaceBindings = XSDNode.namespaceBindings(of: schema)
context.complexTypeNodes = indexByName(allChildren(containers, named: "complexType"))
context.globalAttributes = indexByName(allChildren(containers, named: "attribute"))
context.globalElements = globalElementNodes(containers, namespaceMap: namespaceMap, mainTargetNamespace: mainTargetNamespace)
let redefinedAttributeGroups = PureXML.Schema.XSDParser.redefinedNames(containers, "attributeGroup")
let redefinedGroups = PureXML.Schema.XSDParser.redefinedNames(containers, "group")
let redefinedComplexTypes = PureXML.Schema.XSDParser.redefinedNames(containers, "complexType")
context.redefinedAttributeGroups = redefinedAttributeGroups
context.redefinedGroups = redefinedGroups
context.redefinedComplexTypes = redefinedComplexTypes
context.baseAttributeGroups = baseNamedComponents(containers, named: "attributeGroup")
context.baseGroups = baseNamedComponents(containers, named: "group")
context.baseComplexTypeNodes = baseNamedComponents(containers, named: "complexType")
context.chameleonTargetNamespaces = chameleonTargetNamespaces(containers, locations: containerLocations)
return context
}
private static func chameleonTargetNamespaces(
_ containers: [XSDTree],
locations: [ObjectIdentifier: String?],
) -> [ObjectIdentifier: String] {
var map: [ObjectIdentifier: String] = [:]
let schemas = containers.filter { XSDNode.localName($0) == "schema" }
for included in schemas {
let declared = XSDNode.attribute(included, "targetNamespace")
guard declared == nil || declared?.isEmpty == true else { continue }
guard let includedLocation = locations[ObjectIdentifier(included)] ?? nil else { continue }
let includedBase = includedLocation.split(separator: "/").last.map(String.init) ?? includedLocation
for parent in schemas where parent !== included {
guard let parentNamespace = XSDNode.attribute(parent, "targetNamespace"), !parentNamespace.isEmpty else { continue }
for child in XSDNode.elementChildren(parent) where XSDNode.localName(child) == "include" {
let href = XSDNode.attribute(child, "schemaLocation") ?? ""
if href == includedBase || includedLocation.hasSuffix(href) {
map[ObjectIdentifier(included)] = parentNamespace
}
}
}
}
return map
}
private struct CompiledBuild {
var elements: [String: ElementType]
var types: [String: ElementType]
var globalAttributes: [String: AttributeUse]
var containers: [XSDTree]
var derivation: DerivationTables
var context: XSDContext
var identityFieldTypes: [String: SimpleType]
var identityFieldConstraints: [String: ValueConstraint]
var identityNonSimpleFields: Set<String>
}
static func finishCompile(
schema: XSDTree,
containers: [XSDTree],
derivation: DerivationTables,
context: inout PureXML.Schema.XSDContext,
) -> PureXML.Schema.XSDCompiled {
for error in PureXML.Validation.SchemaCompile.preCompileErrors(schema: schema, context: context, containers: containers) {
context.diagnostics.report(error)
}
seedElementNamespaces(containers, &context)
var types = namedTypes(containers, into: &context)
let elements = globalElements(containers, &context, into: &types)
let globalAttributes = globalAttributeUses(containers, context)
let postCompileDocument = PureXML.Schema.SchemaCompileContext(
schema: schema,
context: context,
containers: containers,
derivation: derivation,
globalElements: elements,
namedTypes: types,
)
for error in PureXML.Validation.SchemaCompile.postCompileErrors(in: postCompileDocument) {
context.diagnostics.report(error)
}
return buildCompiled(CompiledBuild(
elements: elements,
types: types,
globalAttributes: globalAttributes,
containers: containers,
derivation: derivation,
context: context,
identityFieldTypes: identityFieldTypes(containers, context),
identityFieldConstraints: identityFieldConstraints(containers, context),
identityNonSimpleFields: identityNonSimpleFieldKeys(containers, context),
))
}
private static func buildCompiled(_ build: CompiledBuild) -> XSDCompiled {
let (nillable, elementConstraints) = elementMetadata(build.containers)
return XSDCompiled(
elements: build.elements,
types: build.types,
constraints: identityConstraints(build.containers),
identityFieldTypes: build.identityFieldTypes,
identityFieldConstraints: build.identityFieldConstraints,
identityNonSimpleFields: build.identityNonSimpleFields,
nillableElements: nillable,
elementConstraints: elementConstraints,
abstractTypes: build.derivation.abstractTypes,
abstractElements: build.derivation.abstractElements,
typeBlock: build.derivation.typeBlock,
elementBlock: build.derivation.elementBlock,
typeDerivation: build.derivation.typeDerivation,
nsAbstractTypes: build.derivation.nsAbstractTypes,
nsTypeBlock: build.derivation.nsTypeBlock,
nsElementBlock: build.derivation.nsElementBlock,
nsTypeDerivation: build.derivation.nsTypeDerivation,
nsUnionMembers: build.derivation.nsUnionMembers,
typeFinal: build.derivation.typeFinal,
targetNamespace: build.context.targetNamespace,
schemaErrors: build.context.diagnostics.deduplicated,
globalAttributes: build.globalAttributes,
)
}
private static func namedTypes(_ containers: [XSDTree], into context: inout XSDContext) -> [String: ElementType] {
var types: [String: ElementType] = [:]
for container in containers {
let kind = XSDNode.localName(container)
guard kind == "schema" || kind == "redefine" else { continue }
var containerContext = scopedContext(from: context, container: container)
let simpleTypeNodes = kind == "redefine"
? XSDNode.elementChildren(container).filter { XSDNode.localName($0) == "simpleType" }
: XSDNode.children(container, named: "simpleType")
resolveSimpleTypes(simpleTypeNodes, into: &containerContext)
for (name, simple) in containerContext.simpleTypes {
context.simpleTypes[name] = simple
let compiled = ElementType.simple(simple)
let key = typeDeclarationKey(name, namespaceURI: containerContext.targetNamespace)
types[key] = compiled
types[name] = compiled
}
let complexTypeNodes = kind == "redefine"
? XSDNode.elementChildren(container).filter { XSDNode.localName($0) == "complexType" }
: XSDNode.children(container, named: "complexType")
for node in complexTypeNodes {
guard let name = XSDNode.attribute(node, "name") else { continue }
let compiled = ElementType.complex(complexType(node, containerContext.visitingType(name)))
let key = typeDeclarationKey(name, namespaceURI: containerContext.targetNamespace)
types[key] = compiled
types[name] = compiled
}
}
return types
}
private static func scopedContext(from context: XSDContext, container: XSDTree) -> XSDContext {
context.scoped(for: container)
}
/// Records each global element's target namespace before types are compiled,
/// so substitution-group expansion during particle compilation sees the right
/// namespace for imported members (for example `add:salutation` in ipo6).
private static func seedElementNamespaces(_ containers: [XSDTree], _ context: inout XSDContext) {
for container in containers where XSDNode.localName(container) == "schema" {
let containerContext = scopedContext(from: context, container: container)
for node in XSDNode.children(container, named: "element") {
guard XSDNode.attribute(node, "name") != nil, XSDNode.attribute(node, "ref") == nil else { continue }
let name = XSDNode.attribute(node, "name") ?? ""
context.elementNamespaces[name] = containerContext.targetNamespace
}
}
}
private static func globalElements(
_ containers: [XSDTree],
_ context: inout XSDContext,
into types: inout [String: ElementType],
) -> [String: ElementType] {
var elements: [String: ElementType] = [:]
for container in containers where XSDNode.localName(container) == "schema" {
let containerContext = scopedContext(from: context, container: container)
for node in XSDNode.children(container, named: "element") {
guard XSDNode.attribute(node, "name") != nil, XSDNode.attribute(node, "ref") == nil else { continue }
let name = XSDNode.attribute(node, "name") ?? ""
context.elementNamespaces[name] = containerContext.targetNamespace
let type = elementType(node, containerContext)
elements[name] = type
types[elementKey(name)] = type
let qualified = PureXML.Model.QualifiedName(localName: name, namespaceURI: containerContext.targetNamespace)
types[elementDeclarationKey(qualified)] = type
}
}
return elements
}
private static func globalAttributeUses(
_ containers: [XSDTree],
_ context: XSDContext,
) -> [String: AttributeUse] {
var attributes: [String: AttributeUse] = [:]
for container in containers where XSDNode.localName(container) == "schema" {
let containerContext = scopedContext(from: context, container: container)
for node in XSDNode.children(container, named: "attribute") {
guard XSDNode.attribute(node, "name") != nil, XSDNode.attribute(node, "ref") == nil,
var use = attributeUse(node, containerContext)
else { continue }
if let target = containerContext.targetNamespace, !target.isEmpty {
use.name = PureXML.Model.QualifiedName(localName: use.name.localName, namespaceURI: target)
let explicitTarget = XSDNode.attribute(container, "targetNamespace")
if explicitTarget == nil || explicitTarget?.isEmpty == true {
use.chameleonUnprefixed = true
}
}
attributes[attributeDeclarationKey(use.name)] = use
}
}
return attributes
}
private static func allChildren(_ containers: [XSDTree], named name: String) -> [XSDTree] {
containers.flatMap { XSDNode.children($0, named: name) }
}
private static func globalElementNodes(
_ containers: [XSDTree],
namespaceMap: [Int: String?],
mainTargetNamespace: String?,
) -> [String: XSDTree] {
var index: [String: XSDTree] = [:]
for containerIndex in containers.indices {
let container = containers[containerIndex]
guard XSDNode.localName(container) == "schema" else { continue }
let namespace = namespaceMap[containerIndex] ?? mainTargetNamespace
for node in XSDNode.children(container, named: "element") {
guard let name = XSDNode.attribute(node, "name"), XSDNode.attribute(node, "ref") == nil else { continue }
index[derivationKey(name, in: namespace)] = node
}
}
return index
}
/// Named component definitions from every container except `redefine`
/// overlays, so a redefinition's self-reference resolves to the included
/// definition rather than the overlay being built.
private static func baseNamedComponents(_ containers: [XSDTree], named kind: String) -> [String: XSDTree] {
var index: [String: XSDTree] = [:]
for container in containers where XSDNode.localName(container) != "redefine" {
for node in XSDNode.children(container, named: kind) {
guard let name = XSDNode.attribute(node, "name") else { continue }
index[name] = node
}
}
return index
}
/// Resolves the named simple types into `context` over repeated passes:
/// named simple types may restrict or list one another regardless of
/// document order, so each pass sees the types the previous one resolved. A
/// dependency chain of length n settles in at most n passes.
private static func resolveSimpleTypes(_ nodes: [XSDTree], into context: inout XSDContext) {
let named = nodes.filter { XSDNode.attribute($0, "name") != nil }
let nodeByName = simpleTypeNodesByName(named)
// Resolve in dependency order (base before derived) so each type is compiled once,
// not re-resolved N times by the former fixpoint; the resolved set is the same.
for name in simpleTypeResolutionOrder(named, nodeByName: nodeByName) {
guard let node = nodeByName[name] else { continue }
context.simpleTypes[name] = XSDSimpleParser.simpleType(node, context)
}
}
// MARK: Complex types
static func complexType(_ node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.ComplexType {
let mixed = XSDNode.attribute(node, "mixed").map { $0 == "true" || $0 == "1" } ?? false
let attributes = attributeUses(under: node, context)
if let simpleContent = XSDNode.firstChild(node, named: "simpleContent") {
let inner = derivation(simpleContent)
let wildcard = inner.flatMap { attributeWildcard(under: $0, context) }
let derived = inner.map { PureXML.Schema.XSDParser.simpleContentAttributes(under: $0, context) } ?? []
return ComplexType(
attributes: attributes + derived,
attributeWildcard: wildcard,
content: .simpleContent(simpleContentType(simpleContent, context)),
)
}
if let complexContent = XSDNode.firstChild(node, named: "complexContent"), let inner = derivation(complexContent) {
return complexContentType(inner, mixed: mixed || XSDNode.attribute(complexContent, "mixed").map { $0 == "true" || $0 == "1" } ?? false, context)
}
let wildcard = attributeWildcard(under: node, context)
guard let particle = modelGroup(in: node, context) else {
// A mixed type with no content model still permits character data
// (just no child elements): mixed content over an empty particle,
// not the empty content type, which forbids text.
guard mixed else {
return ComplexType(attributes: attributes, attributeWildcard: wildcard, content: .empty)
}
let emptyParticle = Particle(term: .group(.init(compositor: .sequence, particles: [])))
return ComplexType(attributes: attributes, attributeWildcard: wildcard, content: .mixed(emptyParticle))
}
return ComplexType(attributes: attributes, attributeWildcard: wildcard, content: mixed ? .mixed(particle) : .elementOnly(particle))
}
}