fix(schema): reject an all-group referenced inside a compositor (cos-all-limited)

An xs:all group may only be the whole content model of a complex type. A direct
all nested in a sequence/choice was already rejected; this closes the case where
an all is reached through a <group ref> to a named group whose content is an all
and that reference appears inside a compositor.

The all-group pool is qualified by the defining schema's target namespace, and a
reference by its prefix binding, so an imported or redefined same-named group is
not confused with a local one (two over-rejections found by an adversarial
critic: a redefined all-group whose content is overridden, and an imported
all-group shadowing a local non-all group; both fixed and tested). A group
redefined to non-all content is exempt.

XSTS: invalid schemas accepted 481 to 480, no other bucket raised. Disclosed
under-rejections: a redefine that keeps the group an all, and cross-namespace
misplacement of an imported all-group.
This commit is contained in:
Mihaela Mihaljevic 2026-06-15 12:38:30 +02:00
parent 4996d75879
commit ad110b101c
6 changed files with 129 additions and 3 deletions

View File

@ -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 `<group ref>` 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 `<xs:element>` or `<xs:attribute>` carrying both a `type=` and an inline `<simpleType>` (or `<complexType>`, 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.

View File

@ -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 `<group ref>` 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<String> = []
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<String> = []
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<String>, _ 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<String>, _ 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
}
}

View File

@ -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
}
}

View File

@ -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("<xs:schema \(xsd)>\(body)</xs:schema>")) != nil
}
@Test("An all-group referenced inside a compositor is rejected; as the whole content it compiles")
func test_allGroupReferencePlacement() {
let allGroup = "<xs:group name=\"g\"><xs:all><xs:element name=\"a\"/></xs:all></xs:group>"
// Referenced inside a sequence: the all is not the whole content model.
#expect(!compiles(allGroup + "<xs:complexType name=\"T\"><xs:sequence>"
+ "<xs:element name=\"b\"/><xs:group ref=\"g\"/></xs:sequence></xs:complexType>"))
// Referenced inside a choice: likewise rejected.
#expect(!compiles(allGroup + "<xs:complexType name=\"T\"><xs:choice>"
+ "<xs:group ref=\"g\"/></xs:choice></xs:complexType>"))
// Referenced as the whole content model: valid.
#expect(compiles(allGroup + "<xs:complexType name=\"T\"><xs:group ref=\"g\"/></xs:complexType>"))
}
@Test("A non-all group referenced inside a compositor is fine")
func test_nonAllGroupReferenceAccepted() {
let group = "<xs:group name=\"g\"><xs:sequence><xs:element name=\"a\"/></xs:sequence></xs:group>"
#expect(compiles(group + "<xs:complexType name=\"T\"><xs:sequence>"
+ "<xs:group ref=\"g\"/></xs:sequence></xs:complexType>"))
}
/// 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 = "<xs:schema \(xsd) targetNamespace=\"urn:imp\">"
+ "<xs:group name=\"g\"><xs:all><xs:element name=\"a\"/></xs:all></xs:group>"
+ "</xs:schema>"
let main = "<xs:schema \(xsd) targetNamespace=\"urn:main\" xmlns=\"urn:main\" xmlns:imp=\"urn:imp\">"
+ "<xs:import namespace=\"urn:imp\" schemaLocation=\"imp.xsd\"/>"
+ "<xs:group name=\"g\"><xs:sequence><xs:element name=\"x\"/></xs:sequence></xs:group>"
+ "<xs:complexType name=\"T\"><xs:sequence><xs:group ref=\"g\"/></xs:sequence></xs:complexType>"
+ "</xs:schema>"
let document = try? PureXML.Schema.Document(main, schemaLoader: { $0 == "imp.xsd" ? imported : nil })
#expect(document != nil)
}
}

View File

@ -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

View File

@ -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 | |