From 434bfc85ea6f7959c72625a06fa3bef6606018c1 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 15 Jun 2026 22:57:00 +0200 Subject: [PATCH] fix(schema): validate duplicate names across separate files with identical content --- CHANGELOG.md | 1 + Sources/Schema/SchemaIDAttribute.swift | 2 +- Sources/Schema/SchemaNameUniqueness.swift | 20 +- Sources/Schema/XSDContext.swift | 17 +- Sources/Schema/XSDParser.swift | 658 +++++++++++----------- Sources/Schema/XSDParserHelpers.swift | 60 ++ Tests/SchemaNameUniquenessTests.swift | 60 +- Tests/XSTSSuiteTests.swift | 2 +- docs/schema-validity-burndown.md | 2 +- 9 files changed, 473 insertions(+), 349 deletions(-) create mode 100644 Sources/Schema/XSDParserHelpers.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c66fb..e9a3e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Validate duplicate schema container names across separate files with identical content (XSD 1.0 Structures §3.14). Deduplicate schema include/import containers during compilation using both targetNamespace/structural equality and their relative schemaLocation paths to prevent false deduplication of separate documents. Keep circular self-imports/includes of the root schema valid. XSTS: invalid schemas accepted 304 to 302, no other bucket raised. - Validate standard `xml:lang` and `xml:space` attributes on XSD schema elements (like `` and ``). Traversal is adjusted to process attributes on these documentation-related elements before skipping their foreign children, and validation checks the lexical pattern of `xml:lang` (using BCP 47) and restricts `xml:space` to 'default' or 'preserve'. XSTS: invalid schemas accepted 306 to 304, no other bucket raised. - Validate notation declarations and notation enumeration constraints on simpleTypes (XSD 1.0 Structures §3.12 / §3.14). Every `` declaration must specify at least one of `public` or `system` attributes. For any simpleType restricting `xs:NOTATION`, all `enumeration` values must name a declared notation in the schema. Resolved prefix bindings are traversed recursively up the ancestor path to handle default namespaces and local namespace declarations. XSTS: invalid schemas accepted 309 to 306, no other bucket raised. - Enforce cross-container global component-name and identity-constraint uniqueness constraints (XSD 1.0 Structures §3.14). Within a schema (including all included and imported schemas), global type names (simpleType and complexType sharing one symbol space), global element names, global attribute names, named model groups, and named attribute groups must be unique within their respective symbol spaces and target namespaces. Identity constraints (unique, key, keyref) must have unique names across all target namespaces in the entire schema document. Circular and duplicate imports/includes are handled namespace-aware without causing false-positive validations on valid schemas. XSTS: invalid schemas accepted 316 to 309, no other bucket raised. diff --git a/Sources/Schema/SchemaIDAttribute.swift b/Sources/Schema/SchemaIDAttribute.swift index 2ccd20b..9eaf3f4 100644 --- a/Sources/Schema/SchemaIDAttribute.swift +++ b/Sources/Schema/SchemaIDAttribute.swift @@ -46,7 +46,7 @@ extension PureXML.Schema.XSDParser { /// inferring a long concatenation. static func consistencyErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ containers: [XSDTree]) -> [String] { let structural = idAttributeErrors(schema) + structureErrors(schema) - + componentNameErrors(schema, containers, context.targetNamespace) + + componentNameErrors(schema, containers, context) + simpleTypeFinalErrors(schema) let determinism = PureXML.Schema.ContentModelDeterminism.violations(in: schema, context: context) let cycles = derivationCycleErrors(containers, context.namespaceBindings, context.targetNamespace) diff --git a/Sources/Schema/SchemaNameUniqueness.swift b/Sources/Schema/SchemaNameUniqueness.swift index 95b003f..d439952 100644 --- a/Sources/Schema/SchemaNameUniqueness.swift +++ b/Sources/Schema/SchemaNameUniqueness.swift @@ -9,21 +9,26 @@ extension PureXML.Schema.XSDParser { /// Only the document's own globals are examined (`xs:redefine` children and /// included documents are not direct globals here), so a redefinition is not /// mistaken for a clash. - static func componentNameErrors(_ schema: XSDTree, _ containers: [XSDTree], _ mainTargetNamespace: String?) -> [String] { - let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: mainTargetNamespace) - let uniqueIndices = findUniqueIndices(in: containers, namespaceMap: namespaceMap, mainTargetNamespace: mainTargetNamespace) + static func componentNameErrors(_ schema: XSDTree, _ containers: [XSDTree], _ context: PureXML.Schema.XSDContext) -> [String] { + let namespaceMap = resolveContainerNamespaces(containers, mainTargetNamespace: context.targetNamespace) + let uniqueIndices = findUniqueIndices( + in: containers, + containerLocations: context.containerLocations, + namespaceMap: namespaceMap, + mainTargetNamespace: context.targetNamespace, + ) var errors = checkDuplicateGlobals( in: containers, indices: uniqueIndices, namespaceMap: namespaceMap, - mainTargetNamespace: mainTargetNamespace, + mainTargetNamespace: context.targetNamespace, ) errors += identityConstraintNameErrors( containers, uniqueIndices, namespaceMap: namespaceMap, - mainTargetNamespace: mainTargetNamespace, + mainTargetNamespace: context.targetNamespace, ) errors += keyrefReferErrors(schema) return errors @@ -31,6 +36,7 @@ extension PureXML.Schema.XSDParser { private static func findUniqueIndices( in containers: [XSDTree], + containerLocations: [ObjectIdentifier: String?], namespaceMap: [Int: String?], mainTargetNamespace: String?, ) -> [Int] { @@ -38,9 +44,11 @@ extension PureXML.Schema.XSDParser { for index in containers.indices { let container = containers[index] let namespaceURI = namespaceMap[index] ?? mainTargetNamespace + let location = containerLocations[ObjectIdentifier(container)] ?? nil let alreadyExists = uniqueIndices.contains { prevIndex in let prevNS = namespaceMap[prevIndex] ?? mainTargetNamespace - return prevNS == namespaceURI && isStructurallyEqual(containers[prevIndex], container) + let prevLocation = containerLocations[ObjectIdentifier(containers[prevIndex])] ?? nil + return prevNS == namespaceURI && prevLocation == location && isStructurallyEqual(containers[prevIndex], container) } if !alreadyExists { uniqueIndices.append(index) diff --git a/Sources/Schema/XSDContext.swift b/Sources/Schema/XSDContext.swift index 3f46613..4ae9944 100644 --- a/Sources/Schema/XSDContext.swift +++ b/Sources/Schema/XSDContext.swift @@ -76,8 +76,9 @@ extension PureXML.Schema { _ schema: XSDTree, _ loader: (String) -> String?, _ visited: inout Set, - ) -> [XSDTree] { - var containers: [XSDTree] = [] + currentLocation: String? = nil, + ) -> [(location: String?, tree: XSDTree)] { + var containers: [(location: String?, tree: XSDTree)] = [] for child in elementChildren(schema) { let kind = localName(child) guard kind == "include" || kind == "import" || kind == "redefine", @@ -88,10 +89,14 @@ extension PureXML.Schema { visited.insert(location) let sub = loader(location).flatMap { try? PureXML.parseTree($0) } .flatMap { root in elementChildren(root).first { localName($0) == "schema" } } - if let sub { containers += collectContainers(sub, loader, &visited) } - if kind == "redefine" { containers.append(child) } + if let sub { + containers += collectContainers(sub, loader, &visited, currentLocation: location) + } + if kind == "redefine" { + containers.append((location: location, tree: child)) + } } - containers.append(schema) + containers.append((location: currentLocation, tree: schema)) return containers } @@ -226,6 +231,8 @@ extension PureXML.Schema { /// Named complex types being resolved up the current `complexContent` /// derivation chain, a guard against a cyclic base reference. var visitingTypes: Set = [] + /// The location of each container schema document. + var containerLocations: [ObjectIdentifier: String?] = [:] /// The shared schema-validity finding collector. A reference type, so the /// value-type context's copies all report into the one place. let diagnostics = SchemaDiagnostics() diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index 6cf327a..b236284 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -1,3 +1,18 @@ +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 XSDCompiled = PureXML.Schema.XSDCompiled + extension PureXML.Schema { /// Parses an XSD schema document into its global element declarations and its /// named-type table. Matches the schema vocabulary by local name, so the XML @@ -13,297 +28,337 @@ extension PureXML.Schema { throw PureXML.Schema.SchemaError.notASchema } var visited: Set = [] - let containers = XSDNode.collectContainers(schema, loader, &visited) + 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) + let containers = containerTuples.map(\.tree) let derivation = derivationTables(containers) - // checkRedefine and checkAllGroups examine the raw schema source, the - // schema-compilation analog of well-formedness, so they stay throws; - // the model-level consistency checks (final, restriction subsets) are - // Validation rules collected by Schema.Document. try checkRedefine(containers) try checkAllGroups(containers) - var context = XSDContext( - simpleTypes: [:], - attributeGroups: indexByName(allChildren(containers, named: "attributeGroup")), - groups: indexByName(allChildren(containers, named: "group")), - targetNamespace: XSDNode.attribute(schema, "targetNamespace"), - substitutions: filterSubstitutions(XSDNode.substitutionMembers(containers), derivation), - abstractElements: derivation.abstractElements, + var containerLocations: [ObjectIdentifier: String?] = [:] + for (loc, tree) in containerTuples { + let actualLoc = loc ?? rootLocation + containerLocations[ObjectIdentifier(tree)] = actualLoc + } + var context = createContext( + schema: schema, + containers: containers, + derivation: derivation, + containerLocations: containerLocations, ) - 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")) for error in consistencyErrors(schema, context, containers) { context.diagnostics.report(error) } var types = namedTypes(containers, into: &context) let elements = globalElements(containers, context, into: &types) - // Attribute-use uniqueness / single-ID run here, after `namedTypes` - // populated `simpleTypes`, so a named user type derived from `xs:ID` counts. for error in postNamedTypeErrors(schema, context, containers, derivation, typeMaps: (elements, types)) { context.diagnostics.report(error) } - let (nillable, elementConstraints) = elementMetadata(containers) - return XSDCompiled( + return buildCompiled( elements: elements, types: types, - constraints: identityConstraints(containers), - nillableElements: nillable, - elementConstraints: elementConstraints, - abstractTypes: derivation.abstractTypes, - abstractElements: derivation.abstractElements, - typeBlock: derivation.typeBlock, - elementBlock: derivation.elementBlock, - typeDerivation: derivation.typeDerivation, - typeFinal: derivation.typeFinal, - targetNamespace: context.targetNamespace, - schemaErrors: context.diagnostics.deduplicated, + containers: containers, + derivation: derivation, + context: context, ) } - - /// The named-type table: the global simple types resolved into `context`, - /// then each global complex type compiled and added by name. - private static func namedTypes(_ containers: [XSDTree], into context: inout XSDContext) -> [String: ElementType] { - resolveSimpleTypes(allChildren(containers, named: "simpleType"), into: &context) - var types: [String: ElementType] = context.simpleTypes.mapValues(ElementType.simple) - for node in allChildren(containers, named: "complexType") { - if let name = XSDNode.attribute(node, "name") { - types[name] = .complex(complexType(node, context)) - } - } - return types - } - - /// The global element declarations, also registering each under a reserved - /// `element:name` key in `types` so an `xs:element ref="name"` resolves to - /// this declaration's type (a colon keeps it out of the NCName-only type - /// namespace), letting forward and recursive refs resolve at validation time. - private static func globalElements( - _ containers: [XSDTree], - _ context: XSDContext, - into types: inout [String: ElementType], - ) -> [String: ElementType] { - var elements: [String: ElementType] = [:] - for node in allChildren(containers, named: "element") where XSDNode.attribute(node, "name") != nil { - let name = XSDNode.attribute(node, "name") ?? "" - let type = elementType(node, context) - elements[name] = type - types[elementKey(name)] = type - } - return elements - } - - private static func allChildren(_ containers: [XSDTree], named name: String) -> [XSDTree] { - containers.flatMap { XSDNode.children($0, named: name) } - } - - /// 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 } - for _ in named.indices { - for node in named { - if let name = XSDNode.attribute(node, "name") { - context.simpleTypes[name] = XSDSimpleParser.simpleType(node, context) - } - } - } - } - - static func elementKey(_ name: String) -> String { - "element:\(name)" - } - - private static func indexByName(_ nodes: [XSDTree]) -> [String: XSDTree] { - var index: [String: XSDTree] = [:] - for node in nodes { - if let name = XSDNode.attribute(node, "name") { index[name] = node } - } - return index - } - - // MARK: Element and type references - - /// The derivation identity of an element's type: the resolved local name of - /// its `type` reference, `anyType` when the type is absent (the implied type), - /// or nil when the type is an inline anonymous definition (no name to derive - /// against). - static func elementTypeName(_ node: XSDTree) -> String? { - if let typeName = XSDNode.attribute(node, "type") { - return XSDNode.stripPrefix(typeName) - } - if XSDNode.firstChild(node, named: "simpleType") != nil || XSDNode.firstChild(node, named: "complexType") != nil { - return nil - } - return "anyType" - } - - private static func elementType(_ node: XSDTree, _ context: XSDContext) -> ElementType { - if let typeName = XSDNode.attribute(node, "type") { - return typeReference(typeName) - } - if let inline = XSDNode.firstChild(node, named: "simpleType") { - return .simple(XSDSimpleParser.simpleType(inline, context)) - } - if let inline = XSDNode.firstChild(node, named: "complexType") { - return .complex(complexType(inline, context)) - } - return .complex(anyType) - } - - private static func typeReference(_ typeName: String) -> ElementType { - let local = XSDNode.stripPrefix(typeName) - if let builtin = BuiltinType(rawValue: local) { - return .simple(SimpleType(base: builtin)) - } - if let item = XSDSimpleParser.listBuiltinItem(local) { - return .simple(.list(item: SimpleType(base: item))) - } - return .typeReference(local) - } - - private static var anyType: ComplexType { - ComplexType( - attributeWildcard: Wildcard(processContents: .skip), - content: .mixed(Particle(minOccurs: 0, maxOccurs: nil, term: .wildcard(Wildcard(processContents: .skip)))), - ) - } - - // MARK: Complex types - - static func complexType(_ node: XSDTree, _ context: XSDContext) -> ComplexType { - let mixed = XSDNode.attribute(node, "mixed") == "true" - 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 extra = inner.map { attributeUses(under: $0, context) } ?? [] - return ComplexType(attributes: attributes + extra, 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") == "true", 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)) - } - - private static func derivation(_ node: XSDTree) -> XSDTree? { - XSDNode.firstChild(node, named: "restriction") ?? XSDNode.firstChild(node, named: "extension") - } - - private static func simpleContentType(_ node: XSDTree, _ context: XSDContext) -> SimpleType { - guard let inner = derivation(node) else { return SimpleType(base: .string) } - let baseName = XSDNode.stripPrefix(XSDNode.attribute(inner, "base") ?? "string") - let base = BuiltinType(rawValue: baseName) ?? context.simpleTypes[baseName]?.base ?? .string - var facets = context.simpleTypes[baseName]?.facets ?? Facets() - XSDSimpleParser.applyFacets(inner, into: &facets) - return SimpleType(base: base, facets: facets) - } - - // MARK: Attribute uses and groups - - /// The `default` or `fixed` value constraint declared on an attribute or - /// element node, if any (`fixed` takes precedence). - static func valueConstraint(of node: XSDTree) -> ValueConstraint? { - if let fixed = XSDNode.attribute(node, "fixed") { return .fixed(fixed) } - if let value = XSDNode.attribute(node, "default") { return .default(value) } - return nil - } - - // MARK: Model groups - - static func modelGroup(in node: XSDTree, _ context: XSDContext) -> Particle? { - for (name, compositor) in [("sequence", Compositor.sequence), ("choice", .choice), ("all", .all)] { - if let group = XSDNode.firstChild(node, named: name) { - return groupParticle(group, compositor, context) - } - } - if let groupRef = XSDNode.firstChild(node, named: "group") { - return particle(groupRef, context) - } - return nil - } - - private static func groupParticle( - _ node: XSDTree, - _ compositor: Compositor, - _ context: XSDContext, - ) -> Particle { - var particles: [Particle] = [] - for child in XSDNode.elementChildren(node) { - if let member = particle(child, context) { particles.append(member) } - } - let (minimum, maximum) = XSDNode.occurrence(node) - return Particle( - minOccurs: minimum, - maxOccurs: maximum, - term: .group(Group(compositor: compositor, particles: particles)), - ) - } - - private static func particle(_ node: XSDTree, _ context: XSDContext) -> Particle? { - let (minimum, maximum) = XSDNode.occurrence(node) - switch XSDNode.localName(node) { - case "element": - return elementParticle(node, minimum, maximum, context) - case "sequence": - return groupParticle(node, .sequence, context) - case "choice": - return groupParticle(node, .choice, context) - case "all": - return groupParticle(node, .all, context) - case "group": - return groupReferenceParticle(node, minimum, maximum, context) - case "any": - return Particle(minOccurs: minimum, maxOccurs: maximum, term: .wildcard(wildcard(node, context))) - default: - return nil - } - } - - private static func elementParticle(_ node: XSDTree, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle { - if let ref = XSDNode.attribute(node, "ref") { - return referenceParticle(ref, minimum, maximum, context) - } - let name = XSDNode.attribute(node, "name") ?? "" - return Particle( - minOccurs: minimum, - maxOccurs: maximum, - term: .element( - name: localElementName(name, XSDNode.attribute(node, "form"), context), - type: elementType(node, context), - typeName: elementTypeName(node), - ), - ) - } - - 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) - guard !context.visitingGroups.contains(name), let definition = context.groups[name], - let inner = modelGroup(in: definition, context.visiting(name)) - else { - return nil - } - return Particle(minOccurs: minimum, maxOccurs: maximum, term: inner.term) - } } } extension PureXML.Schema.XSDParser { + private static func buildCompiled( + elements: [String: ElementType], + types: [String: ElementType], + containers: [XSDTree], + derivation: DerivationTables, + context: XSDContext, + ) -> XSDCompiled { + let (nillable, elementConstraints) = elementMetadata(containers) + return XSDCompiled( + elements: elements, + types: types, + constraints: identityConstraints(containers), + nillableElements: nillable, + elementConstraints: elementConstraints, + abstractTypes: derivation.abstractTypes, + abstractElements: derivation.abstractElements, + typeBlock: derivation.typeBlock, + elementBlock: derivation.elementBlock, + typeDerivation: derivation.typeDerivation, + typeFinal: derivation.typeFinal, + targetNamespace: context.targetNamespace, + schemaErrors: context.diagnostics.deduplicated, + ) + } + + private static func createContext( + schema: XSDTree, + containers: [XSDTree], + derivation: DerivationTables, + containerLocations: [ObjectIdentifier: String?], + ) -> XSDContext { + var context = XSDContext( + simpleTypes: [:], + attributeGroups: indexByName(allChildren(containers, named: "attributeGroup")), + groups: indexByName(allChildren(containers, named: "group")), + targetNamespace: XSDNode.attribute(schema, "targetNamespace"), + substitutions: filterSubstitutions(XSDNode.substitutionMembers(containers), derivation), + abstractElements: derivation.abstractElements, + containerLocations: containerLocations, + ) + 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")) + return context + } + + /// The named-type table: the global simple types resolved into `context`, + /// then each global complex type compiled and added by name. + private static func namedTypes(_ containers: [XSDTree], into context: inout XSDContext) -> [String: ElementType] { + resolveSimpleTypes(allChildren(containers, named: "simpleType"), into: &context) + var types: [String: ElementType] = context.simpleTypes.mapValues(ElementType.simple) + for node in allChildren(containers, named: "complexType") { + if let name = XSDNode.attribute(node, "name") { + types[name] = .complex(complexType(node, context)) + } + } + return types + } + + /// The global element declarations, also registering each under a reserved + /// `element:name` key in `types` so an `xs:element ref="name"` resolves to + /// this declaration's type (a colon keeps it out of the NCName-only type + /// namespace), letting forward and recursive refs resolve at validation time. + private static func globalElements( + _ containers: [XSDTree], + _ context: XSDContext, + into types: inout [String: ElementType], + ) -> [String: ElementType] { + var elements: [String: ElementType] = [:] + for node in allChildren(containers, named: "element") where XSDNode.attribute(node, "name") != nil { + let name = XSDNode.attribute(node, "name") ?? "" + let type = elementType(node, context) + elements[name] = type + types[elementKey(name)] = type + } + return elements + } + + private static func allChildren(_ containers: [XSDTree], named name: String) -> [XSDTree] { + containers.flatMap { XSDNode.children($0, named: name) } + } + + /// 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 } + for _ in named.indices { + for node in named { + if let name = XSDNode.attribute(node, "name") { + context.simpleTypes[name] = XSDSimpleParser.simpleType(node, context) + } + } + } + } + + static func elementKey(_ name: String) -> String { + "element:\(name)" + } + + private static func indexByName(_ nodes: [XSDTree]) -> [String: XSDTree] { + var index: [String: XSDTree] = [:] + for node in nodes { + if let name = XSDNode.attribute(node, "name") { index[name] = node } + } + return index + } + + // MARK: Element and type references + + /// The derivation identity of an element's type: the resolved local name of + /// its `type` reference, `anyType` when the type is absent (the implied type), + /// or nil when the type is an inline anonymous definition (no name to derive + /// against). + static func elementTypeName(_ node: XSDTree) -> String? { + if let typeName = XSDNode.attribute(node, "type") { + return XSDNode.stripPrefix(typeName) + } + if XSDNode.firstChild(node, named: "simpleType") != nil || XSDNode.firstChild(node, named: "complexType") != nil { + return nil + } + return "anyType" + } + + private static func elementType(_ node: XSDTree, _ context: XSDContext) -> ElementType { + if let typeName = XSDNode.attribute(node, "type") { + return typeReference(typeName) + } + if let inline = XSDNode.firstChild(node, named: "simpleType") { + return .simple(XSDSimpleParser.simpleType(inline, context)) + } + if let inline = XSDNode.firstChild(node, named: "complexType") { + return .complex(complexType(inline, context)) + } + return .complex(anyType) + } + + private static func typeReference(_ typeName: String) -> ElementType { + let local = XSDNode.stripPrefix(typeName) + if let builtin = BuiltinType(rawValue: local) { + return .simple(SimpleType(base: builtin)) + } + if let item = XSDSimpleParser.listBuiltinItem(local) { + return .simple(.list(item: SimpleType(base: item))) + } + return .typeReference(local) + } + + private static var anyType: ComplexType { + ComplexType( + attributeWildcard: Wildcard(processContents: .skip), + content: .mixed(Particle(minOccurs: 0, maxOccurs: nil, term: .wildcard(Wildcard(processContents: .skip)))), + ) + } + + // MARK: Complex types + + static func complexType(_ node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.ComplexType { + let mixed = XSDNode.attribute(node, "mixed") == "true" + 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 extra = inner.map { attributeUses(under: $0, context) } ?? [] + return ComplexType(attributes: attributes + extra, 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") == "true", 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)) + } + + private static func derivation(_ node: XSDTree) -> XSDTree? { + XSDNode.firstChild(node, named: "restriction") ?? XSDNode.firstChild(node, named: "extension") + } + + private static func simpleContentType(_ node: XSDTree, _ context: XSDContext) -> SimpleType { + guard let inner = derivation(node) else { return SimpleType(base: .string) } + let baseName = XSDNode.stripPrefix(XSDNode.attribute(inner, "base") ?? "string") + let base = BuiltinType(rawValue: baseName) ?? context.simpleTypes[baseName]?.base ?? .string + var facets = context.simpleTypes[baseName]?.facets ?? Facets() + XSDSimpleParser.applyFacets(inner, into: &facets) + return SimpleType(base: base, facets: facets) + } + + // MARK: Attribute uses and groups + + /// The `default` or `fixed` value constraint declared on an attribute or + /// element node, if any (`fixed` takes precedence). + static func valueConstraint(of node: XSDTree) -> PureXML.Schema.ValueConstraint? { + if let fixed = XSDNode.attribute(node, "fixed") { return .fixed(fixed) } + if let value = XSDNode.attribute(node, "default") { return .default(value) } + return nil + } + + // MARK: Model groups + + static func modelGroup(in node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.Particle? { + for (name, compositor) in [("sequence", Compositor.sequence), ("choice", .choice), ("all", .all)] { + if let group = XSDNode.firstChild(node, named: name) { + return groupParticle(group, compositor, context) + } + } + if let groupRef = XSDNode.firstChild(node, named: "group") { + return particle(groupRef, context) + } + return nil + } + + private static func groupParticle( + _ node: XSDTree, + _ compositor: Compositor, + _ context: XSDContext, + ) -> Particle { + var particles: [Particle] = [] + for child in XSDNode.elementChildren(node) { + if let member = particle(child, context) { particles.append(member) } + } + let (minimum, maximum) = XSDNode.occurrence(node) + return Particle( + minOccurs: minimum, + maxOccurs: maximum, + term: .group(Group(compositor: compositor, particles: particles)), + ) + } + + private static func particle(_ node: XSDTree, _ context: XSDContext) -> Particle? { + let (minimum, maximum) = XSDNode.occurrence(node) + switch XSDNode.localName(node) { + case "element": + return elementParticle(node, minimum, maximum, context) + case "sequence": + return groupParticle(node, .sequence, context) + case "choice": + return groupParticle(node, .choice, context) + case "all": + return groupParticle(node, .all, context) + case "group": + return groupReferenceParticle(node, minimum, maximum, context) + case "any": + return Particle(minOccurs: minimum, maxOccurs: maximum, term: .wildcard(wildcard(node, context))) + default: + return nil + } + } + + private static func elementParticle(_ node: XSDTree, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle { + if let ref = XSDNode.attribute(node, "ref") { + return referenceParticle(ref, minimum, maximum, context) + } + let name = XSDNode.attribute(node, "name") ?? "" + return Particle( + minOccurs: minimum, + maxOccurs: maximum, + term: .element( + name: localElementName(name, XSDNode.attribute(node, "form"), context), + type: elementType(node, context), + typeName: elementTypeName(node), + ), + ) + } + + 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) + guard !context.visitingGroups.contains(name), let definition = context.groups[name], + let inner = modelGroup(in: definition, context.visiting(name)) + else { + return nil + } + return Particle(minOccurs: minimum, maxOccurs: maximum, term: inner.term) + } + /// The particle for an ``. An abstract head may not appear /// itself, only its substitution-group members; a concrete head appears alongside /// them, the reference expanding to a choice over the head and every member. @@ -336,65 +391,4 @@ extension PureXML.Schema.XSDParser { typeName: nil, ) } - - /// 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. - static func elementMetadata(_ containers: [XSDTree]) -> (Set, [String: PureXML.Schema.ValueConstraint]) { - var nillable: Set = [] - var constraints: [String: PureXML.Schema.ValueConstraint] = [:] - for container in containers { - for element in descendants(container, named: "element") { - guard let name = PureXML.Schema.XSDNode.attribute(element, "name") else { continue } - if PureXML.Schema.XSDNode.attribute(element, "nillable") == "true" { nillable.insert(name) } - if let constraint = valueConstraint(of: element) { constraints[name] = constraint } - } - } - return (nillable, constraints) - } - - /// Gathers identity constraints (`unique`, `key`, `keyref`) declared on any - /// element at any depth, keyed by the element's name. - static func identityConstraints(_ containers: [XSDTree]) -> [String: [PureXML.Schema.IdentityConstraint]] { - var map: [String: [PureXML.Schema.IdentityConstraint]] = [:] - for container in containers { - for element in descendants(container, named: "element") { - guard let name = PureXML.Schema.XSDNode.attribute(element, "name") else { continue } - let constraints = PureXML.Schema.XSDNode.elementChildren(element).compactMap(constraint) - if !constraints.isEmpty { map[name, default: []] += constraints } - } - } - return map - } - - private static func constraint(_ node: XSDTree) -> PureXML.Schema.IdentityConstraint? { - let kind: PureXML.Schema.IdentityConstraintKind - switch PureXML.Schema.XSDNode.localName(node) { - case "unique": kind = .unique - case "key": kind = .key - case "keyref": kind = .keyref(refer: PureXML.Schema.XSDNode.stripPrefix(PureXML.Schema.XSDNode.attribute(node, "refer") ?? "")) - default: return nil - } - let selector = PureXML.Schema.XSDNode.firstChild(node, named: "selector").flatMap { PureXML.Schema.XSDNode.attribute($0, "xpath") } ?? "" - let fields = PureXML.Schema.XSDNode.children(node, named: "field").compactMap { PureXML.Schema.XSDNode.attribute($0, "xpath") } - return PureXML.Schema.IdentityConstraint(name: PureXML.Schema.XSDNode.attribute(node, "name") ?? "", kind: kind, selector: selector, fields: fields) - } - - static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] { - var result: [XSDTree] = [] - for child in PureXML.Schema.XSDNode.elementChildren(node) { - if PureXML.Schema.XSDNode.localName(child) == name { result.append(child) } - result += descendants(child, named: name) - } - return result - } } diff --git a/Sources/Schema/XSDParserHelpers.swift b/Sources/Schema/XSDParserHelpers.swift new file mode 100644 index 0000000..d779c9d --- /dev/null +++ b/Sources/Schema/XSDParserHelpers.swift @@ -0,0 +1,60 @@ +extension PureXML.Schema.XSDParser { + /// Gathers `nillable` and `default`/`fixed` value constraints from every + /// element declaration at any depth, keyed by the element's name. + static func elementMetadata(_ containers: [XSDTree]) -> (Set, [String: PureXML.Schema.ValueConstraint]) { + var nillable: Set = [] + var constraints: [String: PureXML.Schema.ValueConstraint] = [:] + for container in containers { + for element in descendants(container, named: "element") { + guard let name = PureXML.Schema.XSDNode.attribute(element, "name") else { continue } + if PureXML.Schema.XSDNode.attribute(element, "nillable") == "true" { nillable.insert(name) } + if let constraint = valueConstraint(of: element) { constraints[name] = constraint } + } + } + return (nillable, constraints) + } + + /// Gathers identity constraints (`unique`, `key`, `keyref`) declared on any + /// element at any depth, keyed by the element's name. + static func identityConstraints(_ containers: [XSDTree]) -> [String: [PureXML.Schema.IdentityConstraint]] { + var map: [String: [PureXML.Schema.IdentityConstraint]] = [:] + for container in containers { + for element in descendants(container, named: "element") { + guard let name = PureXML.Schema.XSDNode.attribute(element, "name") else { continue } + let constraints = PureXML.Schema.XSDNode.elementChildren(element).compactMap(constraint) + if !constraints.isEmpty { map[name, default: []] += constraints } + } + } + return map + } + + private static func constraint(_ node: XSDTree) -> PureXML.Schema.IdentityConstraint? { + let kind: PureXML.Schema.IdentityConstraintKind + switch PureXML.Schema.XSDNode.localName(node) { + case "unique": kind = .unique + case "key": kind = .key + case "keyref": kind = .keyref(refer: PureXML.Schema.XSDNode.stripPrefix(PureXML.Schema.XSDNode.attribute(node, "refer") ?? "")) + default: return nil + } + let selector = PureXML.Schema.XSDNode.firstChild(node, named: "selector").flatMap { PureXML.Schema.XSDNode.attribute($0, "xpath") } ?? "" + let fields = PureXML.Schema.XSDNode.children(node, named: "field").compactMap { PureXML.Schema.XSDNode.attribute($0, "xpath") } + return PureXML.Schema.IdentityConstraint(name: PureXML.Schema.XSDNode.attribute(node, "name") ?? "", kind: kind, selector: selector, fields: fields) + } + + static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] { + var result: [XSDTree] = [] + for child in PureXML.Schema.XSDNode.elementChildren(node) { + if PureXML.Schema.XSDNode.localName(child) == name { result.append(child) } + result += descendants(child, named: name) + } + return result + } + + /// 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) + } +} diff --git a/Tests/SchemaNameUniquenessTests.swift b/Tests/SchemaNameUniquenessTests.swift index 35cd132..2a1e522 100644 --- a/Tests/SchemaNameUniquenessTests.swift +++ b/Tests/SchemaNameUniquenessTests.swift @@ -158,14 +158,68 @@ struct SchemaNameUniquenessTests { func test_debugName() { let path = "/private/tmp/xsts/xmlschema2006-11-06/sunData/IdConstrDefs/name/name00101m/name00101m2.xsd" guard let source = try? String(contentsOfFile: path) else { - print("unreadable file") return } do { _ = try PureXML.Schema.Document(source) - print("COMPILE SUCCESS") } catch { - print("COMPILE FAILURE: \(error)") + // Expected } } + + @Test("duplicate global names in different imported files are rejected even if files are structurally identical") + func test_duplicateAcrossSeparateFilesIdenticalContent() { + let imported = """ + + + + + + """ + let main = """ + + + + + """ + #expect((try? PureXML.Schema.Document(main, schemaLoader: { location in + if location == "sub1.xsd" || location == "sub2.xsd" { + return imported + } + return nil + })) == nil) + } + + @Test("circular import of the same schema does not trigger duplicate global name error") + func test_circularImportSelfDeduplication() throws { + let imported = """ + + + + + + + + + """ + let main = """ + + + + + + + + + """ + _ = try PureXML.Schema.Document(main, schemaLoader: { location in + if location == "sub.xsd" { + return imported + } + if location == "main.xsd" { + return main + } + return nil + }) + } } diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 750e66b..08e516d 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -29,7 +29,7 @@ struct XSTSSuiteTests { /// XSTS` (debug is far slower and the corpus is large). Per-case deviations /// are written to /tmp/xsts-failures.txt for the burn-down. private let knownSchemaValidRejected = 1 - private let knownSchemaInvalidAccepted = 304 + private let knownSchemaInvalidAccepted = 302 private let knownInstanceValidRejected = 171 private let knownInstanceInvalidAccepted = 155 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index 9e32094..0ca8814 100644 --- a/docs/schema-validity-burndown.md +++ b/docs/schema-validity-burndown.md @@ -10,7 +10,7 @@ irreducible tail. Do not deviate to new features while this is open. | Bucket | Count | Reading | |---|---|---| | valid-schemas-rejected | **72 -> 4** | production target is 0: rejecting a correct schema is the most user-hostile defect (#148). Particle-restriction over-rejection fixed: pointless `maxOccurs=0` particles removed, content-free restriction of an EMPTY base, empty-derived emptiable-base, and (#163) the pairwise check over the normalized content model with occurrence subsumption applied only to same-kind pairings (cross-kind element/group, group/element, group/wildcard match occurrence inside, by effective total range / member). #164 resolved element-ref namespaces by prefix binding (the `particlesJj/Jk/Jm/Jn/Js`, `particlesQ/R` cluster, -28). Content-free (`maxOccurs=0`) derived groups and substitution-group declaration order closed `particlesW006`/`elemZ027_a`; the W3C MapAndSum rule (Sequence:Choice, #165) closed `particlesV003`. Remaining 1 (`particlesZ001`): a documented spec ambiguity (RecurseAsIfGroup; the reference rejects it), not a defect. | -| **invalid-schemas-accepted** | **2461 -> 304** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion, elementTypeRestrictionOK NameAndTypeOK union checks, prohibited attribute type-check bypass, keyref arity verification, top-level group/attributeGroup local-attribute checks, simpleType restriction structure and prefix binding checks, namespace-aware reference resolution checks, simpleType variety constraints on list itemType and union memberTypes, targetNamespace scope matching on local schema references, substitution group exclusions based on element final and schema finalDefault attributes, cross-container component-name and identity-constraint uniqueness checks, notation declarations and notation enumeration restrictions, xml:lang and xml:space on documentation/appinfo elements checks landed). | +| **invalid-schemas-accepted** | **2461 -> 302** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion, elementTypeRestrictionOK NameAndTypeOK union checks, prohibited attribute type-check bypass, keyref arity verification, top-level group/attributeGroup local-attribute checks, simpleType restriction structure and prefix binding checks, namespace-aware reference resolution checks, simpleType variety constraints on list itemType and union memberTypes, targetNamespace scope matching on local schema references, substitution group exclusions based on element final and schema finalDefault attributes, cross-container component-name and identity-constraint uniqueness checks, notation declarations and notation enumeration restrictions, xml:lang and xml:space on documentation/appinfo elements, duplicate schema container validation across separate files with identical content checks landed). | | valid-instances-rejected | **233 -> 171** | (instance side; #164's element-ref namespace fix is shared with instance validation, complexContent restriction attributes inheritance and namespace fixes) | | invalid-instances-accepted | **160 -> 155** | (instance side; resolving inline `` restriction base, complexContent restriction attributes prohibition) |