diff --git a/CHANGELOG.md b/CHANGELOG.md index 613e5d3..ae500b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- XSTS invalid-schemas-accepted (#145): an identity constraint's content model is enforced. `unique`, `key`, and `keyref` must contain exactly one `selector` followed by one or more `field`s (after an optional `annotation`); a `field` before the `selector`, a second `selector`, or no `field` is now rejected (previously only child-element membership was checked, not their cardinality or order). invalid-schemas-accepted 110 → 104, no false positive (valid-schemas-rejected held at 0), no other bucket moved. + - XSTS invalid-schemas-accepted (#145): the occurrence-range constraints on model groups are enforced. A particle's `minOccurs` may not exceed its `maxOccurs`, both defaulting to 1 when absent (`p-props-correct.1`), so `` (2 > the default 1) and `` (the default 1 > 0) are rejected; and an `all` group's `maxOccurs` must be 1 and `minOccurs` 0 or 1, with every particle it contains limited to `maxOccurs` 0 or 1 (`cos-all-limited.2`). The previous check fired only when both attributes were present, missing the common default-`maxOccurs` cases. Triple-confirmed against the W3C-labelled suite and libxml2: invalid-schemas-accepted 130 → 110, with no false positive (valid-schemas-rejected held at 0) and no other bucket moved. - 2026-06-16 diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index 174c5f1..5983ee6 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -136,6 +136,8 @@ extension PureXML.Schema.XSDParser { errors += simpleContentOrderErrors(node) case "selector", "field": errors += identityXPathErrors(node, local: local) + case "unique", "key", "keyref": + errors += identityConstraintContentErrors(names) default: break } diff --git a/Sources/Schema/SchemaStructureHelpers.swift b/Sources/Schema/SchemaStructureHelpers.swift index c4b2fe1..eb0d228 100644 --- a/Sources/Schema/SchemaStructureHelpers.swift +++ b/Sources/Schema/SchemaStructureHelpers.swift @@ -227,6 +227,19 @@ extension PureXML.Schema.XSDParser { return errors } + /// The content model of an identity constraint (`unique`/`key`/`keyref`) is + /// `(annotation?, selector, field+)`: exactly one `selector`, then one or more + /// `field`s, in that order. A `field` before the `selector`, a second + /// `selector`, or no `field` is invalid. (`names` is already the XSD-namespace + /// children; misplaced annotation is checked by the general annotation rule.) + static func identityConstraintContentErrors(_ names: [String]) -> [String] { + let meaningful = names.filter { $0 != "annotation" } + let wellFormed = meaningful.first == "selector" + && meaningful.count >= 2 + && meaningful.dropFirst().allSatisfy { $0 == "field" } + return wellFormed ? [] : ["an identity constraint must contain one selector followed by one or more fields"] + } + static func groupChildrenErrors(_: XSDTree, names: [String]) -> [String] { var errors: [String] = [] let compositors = names.count { $0 == "all" || $0 == "choice" || $0 == "sequence" } diff --git a/Tests/SchemaIdentityXPathTests.swift b/Tests/SchemaIdentityXPathTests.swift index f701f6e..fc0b16c 100644 --- a/Tests/SchemaIdentityXPathTests.swift +++ b/Tests/SchemaIdentityXPathTests.swift @@ -68,4 +68,38 @@ struct SchemaIdentityXPathTests { #expect(rejectsField(field), "expected rejection: '\(field)'") } } + + /// The content model of `unique`/`key`/`keyref` is `(annotation?, selector, + /// field+)`: a field before the selector, a second selector, or no field is + /// invalid, while one selector followed by one or more fields (optionally after + /// an annotation) is valid. + private func compilesConstraint(_ body: String) -> Bool { + let document = #""" + + + + \#(body) + + + """# + return (try? PureXML.Schema.Document(document)) != nil + } + + @Test("an identity constraint requires one selector then one or more fields") + func test_identityConstraintContentModel() { + let selector = #""# + let field = #""# + let annotation = "x" + // Valid shapes. + #expect(compilesConstraint(selector + field)) + #expect(compilesConstraint(selector + field + field)) + #expect(compilesConstraint(annotation + selector + field)) + // A field before the selector (idA044 shape). + #expect(!compilesConstraint(field + selector)) + // Two selectors (idA046 shape). + #expect(!compilesConstraint(selector + selector + field)) + // No field, or no selector. + #expect(!compilesConstraint(selector)) + #expect(!compilesConstraint(field)) + } } diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 28bafa4..cbf7015 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -145,8 +145,11 @@ struct XSTSSuiteTests { /// minOccurs may not exceed maxOccurs, both defaulting to 1; cos-all-limited.2: an /// all group's maxOccurs is 1 and minOccurs 0 or 1, its particles' maxOccurs 0 or /// 1) then caught twenty more invalid schemas with no false positive: 130 → 110. + /// Enforcing the identity-constraint content model (`unique`/`key`/`keyref` is + /// `(annotation?, selector, field+)`: exactly one selector, then one or more + /// fields, in order) caught six more with no false positive: 110 → 104. private let knownSchemaValidRejected = 0 - private let knownSchemaInvalidAccepted = 110 + private let knownSchemaInvalidAccepted = 104 private let knownInstanceValidRejected = 0 private let knownInstanceInvalidAccepted = 133