From 39a91c258e2b485c47760a1e97012d63cda66498 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 17 Jun 2026 00:03:13 +0200 Subject: [PATCH] fix(schema): resolve identity-constraint XPath prefixes against the schema (#147) Identity-constraint selector and field XPath prefixes were resolved against the instance document's namespace bindings, so a constraint declared with xmlns:v="..." and a selector like .//v:vehicle matched nothing when the instance used a default namespace instead of the v prefix. The selector then selected no nodes, no key tuples were collected, and duplicate-key and keyref violations went undetected. Prefixes now resolve against the namespace bindings in scope where the constraint is declared in the schema (XSD Part 1, Identity-constraint Definition), captured at parse time onto IdentityConstraint. A QName field value still compares using the instance's bindings, so QName keys are unaffected. XSTS invalid-instances-accepted 143 to 133: the idc004/5/6 namespaced key/keyref cluster and namespace-resolution cases (idZ010 and kin), no other bucket moved. --- CHANGELOG.md | 2 +- Sources/Schema/IdentityConstraint.swift | 14 +++- Sources/Schema/IdentityValidator.swift | 96 ++++++++++++++----------- Sources/Schema/XSDParserHelpers.swift | 8 ++- Tests/XSTSSuiteTests.swift | 8 ++- 5 files changed, 83 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a69ef..e7e0698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Second pre-release. Continues the W3C XSTS schema-validity campaign: **invalid-s ### Fixed -- XSTS invalid-instances-accepted (#147): identity-constraint fields now enforce cardinality (a `field` must select at most one node; cvc-identity-constraint.3), so an instance whose `unique`/`key`/`keyref` field matches several nodes is rejected rather than accepted. Caught four cases (idF005, idG005, and kin): invalid-instances-accepted 147 → 143, no other bucket moved. +- XSTS invalid-instances-accepted (#147): identity constraints reject more invalid instances. Fields now enforce cardinality (a `field` must select at most one node; cvc-identity-constraint.3), so an instance whose `unique`/`key`/`keyref` field matches several nodes is rejected. Selector and field XPath prefixes now resolve against the schema's namespace context (where the constraint is declared) rather than the instance document, so a constraint like `.//v:vehicle` works whatever prefix the instance uses; a QName field value still compares using the instance's bindings. Together these caught fourteen cases (idF005, idG005, the idc004/5/6 cluster, idZ010, and kin): invalid-instances-accepted 147 → 133, no other bucket moved. - XSTS valid-instances-rejected (#146): **173 → 3** on the 2006-11-06 archive (settled-status count; the four buckets now read 1/262/3/143). Regex patterns match Unicode scalars (not Swift grapheme clusters), fixing `\p{Is…}` block tests and combining-mark cases; empty patterns match only the empty string. The regex repertoire is pinned to Unicode 3.2, the version the corpus is consistent at: code points added in 3.2 (Tagalog, Cyrillic Komi) are word characters, while later additions (the Tamil digit zero) stay unassigned and match `\D` (`XSDCategory`, `Unicode32Assigned`). Per-child content assessment is driven by the content-model particle the automaton matched, not a by-name lookup (#180), so two same-named particles keep their own `fixed` values and an element matching a `skip` wildcard is not validated against its global declaration. Schema documents that form an import chain are compiled as a union rather than merged as separately compiled documents, so cross-document substitution groups resolve (#161, `Document.init(composing:)`). Plus attribute-group `anyAttribute` inheritance, extension wildcard union, namespace-qualified type tables for multi-document `xsi:schemaLocation`, per-container `elementFormDefault`, strict/lax attribute wildcard validation, built-in type resolution, and `xs:redefine` simpleType overrides. diff --git a/Sources/Schema/IdentityConstraint.swift b/Sources/Schema/IdentityConstraint.swift index 2e7c356..8368972 100644 --- a/Sources/Schema/IdentityConstraint.swift +++ b/Sources/Schema/IdentityConstraint.swift @@ -20,12 +20,24 @@ public extension PureXML.Schema { public var kind: IdentityConstraintKind public var selector: String public var fields: [String] + /// Prefix-to-namespace bindings in scope where the constraint is declared + /// in the schema. Selector and field XPath prefixes resolve against these + /// (XSD Part 1, Identity-constraint Definition), not against the instance + /// document, so `.//v:vehicle` works whatever prefix the instance uses. + public var namespaceBindings: [String: String] - public init(name: String, kind: IdentityConstraintKind, selector: String, fields: [String]) { + public init( + name: String, + kind: IdentityConstraintKind, + selector: String, + fields: [String], + namespaceBindings: [String: String] = [:], + ) { self.name = name self.kind = kind self.selector = selector self.fields = fields + self.namespaceBindings = namespaceBindings } } } diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift index 4d90425..ff95917 100644 --- a/Sources/Schema/IdentityValidator.swift +++ b/Sources/Schema/IdentityValidator.swift @@ -143,46 +143,19 @@ extension PureXML.Schema { PureXML.Validation.PathKey.steps(forChildNames: children.map { $0.name?.description ?? "" }) } - /// The coding-path steps from the constraint-bearing `container` down to a - /// selected `target`, so an identity error locates the actual offending - /// element rather than the element that merely declares the constraint. - /// Returns an empty path if `target` is not a descendant of `container`. - private func steps(from container: PureXML.Model.TreeNode, to target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] { - var chain: [PureXML.Model.TreeNode] = [] - var current = target - while current !== container { - guard let parent = current.parent else { return [] } - chain.append(current) - current = parent - } - var steps: [PureXML.Validation.PathKey] = [] - var parent = container - for node in chain.reversed() { - let name = node.name?.description ?? "" - let siblings = parent.children.filter { $0.kind == .element && ($0.name?.description ?? "") == name } - if siblings.count > 1, let index = siblings.firstIndex(where: { $0 === node }) { - steps.append(.element(name, index: index + 1)) - } else { - steps.append(.element(name)) - } - parent = node - } - return steps - } - /// The coding-path step from a `target` down to the single field a /// constraint names, so an error locates the offending value (`@id`, a /// child element) rather than just the target. Empty for a multi-field /// constraint or a complex field path, in which case the error stays on the /// target. - private func fieldStep(_ fields: [String], at target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] { + private func fieldStep(_ fields: [String], at target: PureXML.Model.TreeNode, namespaces: [String: String]) -> [PureXML.Validation.PathKey] { guard fields.count == 1 else { return [] } let field = fields[0] if field.hasPrefix("@"), !field.dropFirst().contains(where: { $0 == "/" || $0 == "[" }) { return [.attribute(String(field.dropFirst()))] } let isSimpleName = !field.contains(where: { "/@[(".contains($0) }) - if isSimpleName, let child = select(field, at: target).first, child.kind == .element { + if isSimpleName, let child = select(field, at: target, namespaces: namespaces).first, child.kind == .element { return steps(from: target, to: child) } return [] @@ -203,7 +176,7 @@ extension PureXML.Schema { guard isNonRef(constraint) else { return } var tuples: [[FieldValue?]] = [] var seen: [[FieldValue?]] = [] - for target in select(constraint.selector, at: node) { + for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) { let targetPath = path + steps(from: node, to: target) if let error = fieldCardinalityError(constraint, at: target, path: targetPath) { issues.append(error) @@ -211,12 +184,18 @@ extension PureXML.Schema { } let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint) if constraint.kind == .key, tuple.contains(where: { $0 == nil }) { - issues.append(.init(reason: "key '\(constraint.name)': a field is missing", at: targetPath + fieldStep(constraint.fields, at: target))) + issues.append(.init( + reason: "key '\(constraint.name)': a field is missing", + at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings), + )) continue } if tuple.contains(where: { $0 == nil }) { continue } if seen.contains(where: { $0 == tuple }) { - issues.append(.init(reason: "\(label(constraint)) '\(constraint.name)': duplicate value", at: targetPath + fieldStep(constraint.fields, at: target))) + issues.append(.init( + reason: "\(label(constraint)) '\(constraint.name)': duplicate value", + at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings), + )) } else { seen.append(tuple) } @@ -234,7 +213,7 @@ extension PureXML.Schema { ) { guard case let .keyref(refer) = constraint.kind else { return } let keyTuples = scopes.reversed().compactMap { $0[refer] }.first ?? [] - for target in select(constraint.selector, at: node) { + for target in select(constraint.selector, at: node, namespaces: constraint.namespaceBindings) { let targetPath = path + steps(from: node, to: target) if let error = fieldCardinalityError(constraint, at: target, path: targetPath) { issues.append(error) @@ -243,7 +222,10 @@ extension PureXML.Schema { let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint) if tuple.contains(where: { $0 == nil }) { continue } if !keyTuples.contains(where: { $0 == tuple }) { - issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: targetPath + fieldStep(constraint.fields, at: target))) + issues.append(.init( + reason: "keyref '\(constraint.name)': no matching key '\(refer)'", + at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings), + )) } } } @@ -257,10 +239,10 @@ extension PureXML.Schema { at target: PureXML.Model.TreeNode, path targetPath: [PureXML.Validation.PathKey], ) -> PureXML.Validation.ValidationError? { - for field in constraint.fields where select(field, at: target).count > 1 { + for field in constraint.fields where select(field, at: target, namespaces: constraint.namespaceBindings).count > 1 { return .init( reason: "\(label(constraint)) '\(constraint.name)': field '\(field)' must select at most one node", - at: targetPath + fieldStep(constraint.fields, at: target), + at: targetPath + fieldStep(constraint.fields, at: target, namespaces: constraint.namespaceBindings), ) } return nil @@ -268,23 +250,26 @@ extension PureXML.Schema { // MARK: XPath helpers - private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] { + private func select(_ xpath: String, at node: PureXML.Model.TreeNode, namespaces: [String: String]) -> [PureXML.Model.TreeNode] { guard let query = cache.query(xpath) else { return [] } - return query.nodes(over: node, namespaces: namespaceBindings(at: node)) + return query.nodes(over: node, namespaces: namespaces) } private func fieldTuple(_ fields: [String], at node: PureXML.Model.TreeNode, constraint: IdentityConstraint) -> [FieldValue?] { - let namespaces = namespaceBindings(at: node) + // The field XPath's prefixes resolve against the schema (where the + // constraint is declared); a QName field VALUE resolves against the + // instance, so the FieldValue keeps the instance bindings. + let instanceNamespaces = namespaceBindings(at: node) return fields.map { field in guard let query = cache.query(field), - let value = try? query.value(at: node, namespaces: namespaces) + let value = try? query.value(at: node, namespaces: constraint.namespaceBindings) else { return nil } if let nodes = value.nodes, nodes.isEmpty { return nil } let type = resolveFieldType(field, constraint: constraint, at: node) ?? Self.effectiveType(of: value) - return FieldValue(string: value.string, type: type, namespaceBindings: namespaces) + return FieldValue(string: value.string, type: type, namespaceBindings: instanceNamespaces) } } @@ -363,3 +348,32 @@ extension PureXML.Schema { } } } + +extension PureXML.Schema.IdentityValidator { + /// The coding-path steps from the constraint-bearing `container` down to a + /// selected `target`, so an identity error locates the actual offending + /// element rather than the element that merely declares the constraint. + /// Returns an empty path if `target` is not a descendant of `container`. + func steps(from container: PureXML.Model.TreeNode, to target: PureXML.Model.TreeNode) -> [PureXML.Validation.PathKey] { + var chain: [PureXML.Model.TreeNode] = [] + var current = target + while current !== container { + guard let parent = current.parent else { return [] } + chain.append(current) + current = parent + } + var steps: [PureXML.Validation.PathKey] = [] + var parent = container + for node in chain.reversed() { + let name = node.name?.description ?? "" + let siblings = parent.children.filter { $0.kind == .element && ($0.name?.description ?? "") == name } + if siblings.count > 1, let index = siblings.firstIndex(where: { $0 === node }) { + steps.append(.element(name, index: index + 1)) + } else { + steps.append(.element(name)) + } + parent = node + } + return steps + } +} diff --git a/Sources/Schema/XSDParserHelpers.swift b/Sources/Schema/XSDParserHelpers.swift index 39c9c7b..2035d05 100644 --- a/Sources/Schema/XSDParserHelpers.swift +++ b/Sources/Schema/XSDParserHelpers.swift @@ -156,7 +156,13 @@ extension PureXML.Schema.XSDParser { } 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) + return PureXML.Schema.IdentityConstraint( + name: PureXML.Schema.XSDNode.attribute(node, "name") ?? "", + kind: kind, + selector: selector, + fields: fields, + namespaceBindings: PureXML.Schema.XSDParser.namespaceBindingsInScope(of: node, defaultBindings: [:]), + ) } static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] { diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 6afe153..0b9e2b3 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -57,10 +57,16 @@ struct XSTSSuiteTests { /// one node; cvc-identity-constraint.3) then caught four invalid instances that /// were accepted (idF005, idG005, and kin): invalid accepted 147 → 143, no /// other bucket moved. + /// + /// Resolving identity-constraint selector/field XPath prefixes against the + /// schema's namespace context (where the constraint is declared) rather than + /// the instance document then caught ten more (the idc004/5/6 cluster and + /// namespace-resolution cases): invalid accepted 143 → 133, no other bucket + /// moved. private let knownSchemaValidRejected = 1 private let knownSchemaInvalidAccepted = 262 private let knownInstanceValidRejected = 3 - private let knownInstanceInvalidAccepted = 143 + private let knownInstanceInvalidAccepted = 133 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws {