diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c91fe4..f2c47c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An `xs:all` group referenced through a `` from inside a compositor is now rejected (XSD 1.0 `cos-all-limited.1.2`, #170): an `all` group may only be the whole content model of a complex type. A direct nested `all` was already rejected by the child-content table; this closes the group-reference path. The all-group pool is qualified by the defining schema's target namespace and a reference by its prefix, so an imported or redefined same-named group is not confused with a local one; a group redefined to a non-all content is exempt. XSTS: invalid schemas accepted 481 to 480, no other bucket raised. - An attribute or element whose type is or derives from `xs:ID` may no longer carry a `default` or `fixed` value constraint (XSD 1.0 `a-props-correct.3` / `e-props-correct.5`, #170). Such schemas were accepted. The check runs after named simple types resolve, so a named user type derived from `xs:ID` is recognized; the type reference's namespace is resolved from its prefix, so a user type named `ID` or an imported `foo:ID` is not mistaken for the built-in. XSTS: invalid schemas accepted 489 to 481, no other bucket raised. - A `type` attribute and an inline anonymous type definition are now mutually exclusive on an element or attribute (XSD 1.0 `src-element.3` / `src-attribute.3.1`, #170). An `` or `` carrying both a `type=` and an inline `` (or ``, for an element) is rejected; previously it was accepted. Only an inline type in the XSD namespace counts, so a foreign-namespace child that happens to be named `complexType`/`simpleType` is not mistaken for the component's own type. XSTS: invalid schemas accepted 495 to 489, no other bucket raised. - Attribute-use uniqueness and the single-ID rule are now enforced (XSD 1.0 `ct-props-correct.4` / `ag-props-correct.2` / `ct-props-correct.5`, #162). A complex type's or attribute group's attribute-use set (flattened through `attributeGroup` references) may not contain two distinct declarations with the same name and namespace, nor more than one attribute whose type is or derives from `xs:ID`. Declarations are deduplicated by the identity of their declaring node, so the same declaration reached by two paths (a diamond of attribute-group references) is one use, not a clash; names carry their resolved namespace, so a local attribute and a reference to a same-local-name attribute in an imported namespace do not collide. XSTS: invalid schemas accepted 505 to 495, no other bucket raised. Disclosed under-rejections: the set inherited through extension is not composed in, and a duplicate that requires an unprefixed reference in a schema with no default namespace is not detected. diff --git a/Sources/Schema/SchemaAllGroupPlacement.swift b/Sources/Schema/SchemaAllGroupPlacement.swift new file mode 100644 index 0000000..98aa34c --- /dev/null +++ b/Sources/Schema/SchemaAllGroupPlacement.swift @@ -0,0 +1,73 @@ +private typealias AllNode = PureXML.Schema.XSDNode + +extension PureXML.Schema.XSDParser { + /// XSD 1.0 `cos-all-limited.1.2`: an `all` group may only be the whole content + /// model of a complex type. A direct `all` nested in a `sequence`/`choice` is + /// already rejected by the child-content table; the remaining case is an `all` + /// reached through a `` to a named group whose content is an `all`, + /// where that reference appears inside a compositor (so the `all` is not the + /// whole content). The reference is followed only when it resolves to this + /// schema's own target namespace. + static func allGroupReferencePlacementErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] { + // A redefined group's effective content is the redefinition's, not the + // original loaded definition, so a redefined group name is exempt: its + // original `all` content is overridden (and the suite treats a reference to + // it inside a compositor as valid). + var redefined: Set = [] + for container in containers where AllNode.localName(container) == "redefine" { + for group in AllNode.children(container, named: "group") { + if let name = AllNode.attribute(group, "name") { redefined.insert(name) } + } + } + var allGroups: Set = [] + for container in containers { + // A named group defines a component in the target namespace of the + // schema document that holds it. A reference is only ever followed when + // it resolves to this schema's own `target` (see `allGroupReference`), + // so pooling a same-named all-group from an *imported* namespace would + // wrongly flag a local non-all group referenced inside a compositor. + // Record only all-groups defined in `target`: a `schema` container's + // own namespace is its `targetNamespace`; a `redefine` container's + // redefinitions belong to the including schema (this `target`). + let containerNamespace = AllNode.localName(container) == "redefine" + ? target + : AllNode.attribute(container, "targetNamespace") + guard containerNamespace == target else { continue } + for group in descendants(container, named: "group") { + guard let name = AllNode.attribute(group, "name"), !redefined.contains(name) else { continue } + if AllNode.elementChildren(group).contains(where: { AllNode.localName($0) == "all" }) { + allGroups.insert(name) + } + } + } + guard !allGroups.isEmpty else { return [] } + var errors: [String] = [] + for container in containers { + collectAllGroupRefPlacement(container, allGroups, bindings, target, into: &errors) + } + return errors + } + + private static func collectAllGroupRefPlacement(_ node: XSDTree, _ allGroups: Set, _ bindings: [String: String], _ target: String?, into errors: inout [String]) { + let kind = AllNode.localName(node) + let insideCompositor = kind == "sequence" || kind == "choice" || kind == "all" + for child in AllNode.elementChildren(node) { + if insideCompositor, let referenced = allGroupReference(child, allGroups, bindings, target) { + errors.append("the all-group '\(referenced)' may not be referenced inside a '\(kind ?? "")'; an all group must be the whole content model") + } + collectAllGroupRefPlacement(child, allGroups, bindings, target, into: &errors) + } + } + + /// The name of the all-group `child` references in this schema's target + /// namespace, or nil when `child` is not such a reference. + private static func allGroupReference(_ child: XSDTree, _ allGroups: Set, _ bindings: [String: String], _ target: String?) -> String? { + guard AllNode.localName(child) == "group", let ref = AllNode.attribute(child, "ref"), + AllNode.referenceNamespace(ref, bindings) == target + else { + return nil + } + let name = AllNode.stripPrefix(ref) + return allGroups.contains(name) ? name : nil + } +} diff --git a/Sources/Schema/SchemaIDAttribute.swift b/Sources/Schema/SchemaIDAttribute.swift index c99904d..ad9d694 100644 --- a/Sources/Schema/SchemaIDAttribute.swift +++ b/Sources/Schema/SchemaIDAttribute.swift @@ -49,6 +49,7 @@ extension PureXML.Schema.XSDParser { let determinism = PureXML.Schema.ContentModelDeterminism.violations(in: schema, context: context) let cycles = derivationCycleErrors(containers, context.namespaceBindings, context.targetNamespace) + circularReferenceErrors(containers, context.namespaceBindings, context.targetNamespace) - return structural + determinism + cycles + let placement = allGroupReferencePlacementErrors(containers, context.namespaceBindings, context.targetNamespace) + return structural + determinism + cycles + placement } } diff --git a/Tests/SchemaAllGroupPlacementTests.swift b/Tests/SchemaAllGroupPlacementTests.swift new file mode 100644 index 0000000..732e253 --- /dev/null +++ b/Tests/SchemaAllGroupPlacementTests.swift @@ -0,0 +1,51 @@ +@testable import PureXML +import Testing + +@Suite("XSD all-group placement (cos-all-limited)") +struct SchemaAllGroupPlacementTests { + private let xsd = "xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + private func compiles(_ body: String) -> Bool { + (try? PureXML.Schema.Document("\(body)")) != nil + } + + @Test("An all-group referenced inside a compositor is rejected; as the whole content it compiles") + func test_allGroupReferencePlacement() { + let allGroup = "" + // Referenced inside a sequence: the all is not the whole content model. + #expect(!compiles(allGroup + "" + + "")) + // Referenced inside a choice: likewise rejected. + #expect(!compiles(allGroup + "" + + "")) + // Referenced as the whole content model: valid. + #expect(compiles(allGroup + "")) + } + + @Test("A non-all group referenced inside a compositor is fine") + func test_nonAllGroupReferenceAccepted() { + let group = "" + #expect(compiles(group + "" + + "")) + } + + /// An imported namespace may define an all-group whose local name collides with + /// a *non-all* group in this schema's own target namespace. A reference to the + /// local group inside a compositor resolves to this target namespace and is + /// therefore valid: the all-group lives in a different namespace and is not the + /// referent. All-group names are tracked per defining namespace, so the + /// collision must not cause the valid local reference to be rejected. + @Test("An imported all-group does not shadow a same-named local non-all group") + func test_importedAllGroupDoesNotShadowLocalNonAll() { + let imported = "" + + "" + + "" + let main = "" + + "" + + "" + + "" + + "" + let document = try? PureXML.Schema.Document(main, schemaLoader: { $0 == "imp.xsd" ? imported : nil }) + #expect(document != nil) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 6072b44..a560062 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 = 481 + private let knownSchemaInvalidAccepted = 480 private let knownInstanceValidRejected = 180 private let knownInstanceInvalidAccepted = 160 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index f9143b4..ff60dcb 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 -> 481** | 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 landed). | +| **invalid-schemas-accepted** | **2461 -> 480** | 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 landed). | | valid-instances-rejected | **233 -> 180** | (instance side; #164's element-ref namespace fix is shared with instance validation, -34) | | invalid-instances-accepted | 160 | |