From 9769f022ddc57ec60c4961fc8ded2cfcc2613927 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 16 Jun 2026 23:41:30 +0200 Subject: [PATCH] fix(schema): enforce identity-constraint field cardinality (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An identity-constraint field must evaluate to a node-set with at most one member (cvc-identity-constraint.3). The validator built a value tuple per target without checking how many nodes each field selected, so an instance whose unique/key/keyref field matched several nodes was accepted. fieldCardinalityError now rejects a target whose field selects more than one node, in both the key/unique and keyref paths. XSTS invalid-instances-accepted 4 → fewer (147 to 143), no other bucket moved. --- CHANGELOG.md | 4 +++- Sources/Schema/IdentityValidator.swift | 30 ++++++++++++++++++++++++-- Tests/XSTSSuiteTests.swift | 7 +++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 345df06..e7a69ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,9 @@ Second pre-release. Continues the W3C XSTS schema-validity campaign: **invalid-s ### Fixed -- XSTS valid-instances-rejected (#146): **173 → 3** on the 2006-11-06 archive (settled-status count; the four buckets now read 1/262/3/147). 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. +- 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 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. - Enforce element, attribute, attributeGroup, and group child-content shape (schema-for-schemas cardinality and order): at most one inline type per element/attribute, inline type before identity constraints, at most one compositor per group, at most one `anyAttribute` per attributeGroup with attribute references preceding it (`SchemaStructureHelpers.swift`). diff --git a/Sources/Schema/IdentityValidator.swift b/Sources/Schema/IdentityValidator.swift index 54d9e9d..4d90425 100644 --- a/Sources/Schema/IdentityValidator.swift +++ b/Sources/Schema/IdentityValidator.swift @@ -204,8 +204,12 @@ extension PureXML.Schema { var tuples: [[FieldValue?]] = [] var seen: [[FieldValue?]] = [] for target in select(constraint.selector, at: node) { - let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint) let targetPath = path + steps(from: node, to: target) + if let error = fieldCardinalityError(constraint, at: target, path: targetPath) { + issues.append(error) + continue + } + 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))) continue @@ -231,15 +235,37 @@ 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) { + let targetPath = path + steps(from: node, to: target) + if let error = fieldCardinalityError(constraint, at: target, path: targetPath) { + issues.append(error) + continue + } let tuple = fieldTuple(constraint.fields, at: target, constraint: constraint) if tuple.contains(where: { $0 == nil }) { continue } if !keyTuples.contains(where: { $0 == tuple }) { - let targetPath = path + steps(from: node, to: target) issues.append(.init(reason: "keyref '\(constraint.name)': no matching key '\(refer)'", at: targetPath + fieldStep(constraint.fields, at: target))) } } } + /// The error when a field of `constraint` selects more than one node at + /// `target`. A field must evaluate to a node-set with at most one member + /// (Identity-constraint Definition; cvc-identity-constraint.3); selecting + /// several is a violation, not a multi-valued key. + private func fieldCardinalityError( + _ constraint: IdentityConstraint, + 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 { + return .init( + reason: "\(label(constraint)) '\(constraint.name)': field '\(field)' must select at most one node", + at: targetPath + fieldStep(constraint.fields, at: target), + ) + } + return nil + } + // MARK: XPath helpers private func select(_ xpath: String, at node: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] { diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index e5785f6..6afe153 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -52,10 +52,15 @@ struct XSTSSuiteTests { /// substitution group: instances rejected 4 → 3, no other bucket moved. The /// remaining three are the Ethiopic-digit `\d` cases (reS17, reS38, reZ004v), /// which contradict `\d` = `\p{Nd}` and are tracked as suspect tests. + /// + /// Enforcing identity-constraint field cardinality (a field must select at most + /// 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. private let knownSchemaValidRejected = 1 private let knownSchemaInvalidAccepted = 262 private let knownInstanceValidRejected = 3 - private let knownInstanceInvalidAccepted = 147 + private let knownInstanceInvalidAccepted = 143 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws {