From 9d4ce304c29df1deeb3812f4a44c0c3adad09d0b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sun, 14 Jun 2026 11:15:24 +0200 Subject: [PATCH] feat(schema): enforce complexType shorthand content order A complexType's shorthand content (no simpleContent/complexContent) has the model (annotation?, (group|all|choice|sequence)?, (attribute|attributeGroup)*, anyAttribute?). The existing content-shape check caught the wrong number of model groups and content-spec exclusivity, but not the order, so a model group after an attribute, an attribute after anyAttribute, or a second anyAttribute (the ctB family) was accepted. The complexType's direct children, excluding annotation and the content specs, are matched against the same ordered slot model used for a complexContent derivation. XSTS: invalid schemas accepted 687 to 664, valid schemas rejected held at 72, instance buckets unchanged. --- CHANGELOG.md | 1 + Sources/Schema/SchemaContentOrder.swift | 16 ++++++++++++++++ Sources/Schema/SchemaStructure.swift | 1 + Tests/SchemaStructureTests.swift | 16 ++++++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- docs/schema-validity-burndown.md | 2 +- 6 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfdf3d..9bb197a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A complexType's shorthand content (without `simpleContent`/`complexContent`) is now checked for order, not just cardinality (#158). Its model is `(annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?)`: an optional model group, then attributes, then an optional `anyAttribute`. The existing content-shape check caught the wrong number of model groups and the exclusivity of a content spec, but not the order, so a model group after an attribute, an attribute after `anyAttribute`, or a second `anyAttribute` (the ctB family) was accepted. The complexType's direct children, excluding `annotation` and the content specs, are matched against the same ordered slot model used for a `complexContent` derivation. XSTS: invalid schemas accepted 687 to 664, no other bucket raised (valid schemas rejected held at 72). - An `any`/`anyAttribute` wildcard's `namespace` constraint is now validated at compile time (#158). Its value space is `('##any' | '##other')` standing alone, or a list of namespace URIs and the `##targetNamespace`/`##local` tokens; the wildcard parser mapped any unrecognised token to a literal URI, so a misspelled token (`##all`, `##target`), an `##any`/`##other` placed inside a list (`##any ##other`), or any other `##`-prefixed token was accepted. The unprefixed `namespace` value is now tokenised and checked against that grammar, while `##any`/`##other` are admitted only as the whole value; a prefixed (foreign) attribute that merely shares the local name is the author's own and is not checked, so no valid schema is rejected. XSTS: invalid schemas accepted 736 to 687, no other bucket raised (valid schemas rejected held at 72). - An identity constraint's `selector`/`field` `xpath` is now validated against the XSD 1.0 restricted-XPath subset at compile time (#158). That subset (Part 1, 3.11.6) is far narrower than full XPath: a path is an optional leading `.//` then `/`-separated steps, a step is `.`, a name test (`QName`, `*`, or `NCName:*`), or a `child::` axis, and a `field` path may end in an attribute step (`@x` or `attribute::x`); there are no predicates (`[...]`), no other axes (`self::`, `descendant::`), no absolute or interior `//`. The xpath was stored raw and compiled lazily with `try?`, so a malformed one (a predicate, a bare `/`, an empty union branch, a spaced name-test colon) was silently accepted. It is now lexed into a whitespace-tolerant token stream and parsed against the grammar, so whitespace between tokens is allowed (`child :: a`, `. //.`) while `//` must be adjacent and a name test's own `:` may not be spaced; the name-test lexer uses the XML `NameChar` set, so a non-ASCII NCName is accepted. XSTS: invalid schemas accepted 785 to 736, no other bucket raised (valid schemas rejected held at 72). - A `complexContent` derivation's content is now checked for order and cardinality, not just child membership (#158). A `restriction` or `extension` inside `complexContent` has the content model `(annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?)`: an optional model group, then any number of `attribute`/`attributeGroup`, then an optional `anyAttribute`. The `allowedChildren` membership table admitted the right child names but not their sequence or count, so a second model group (`sequence` then `sequence`), a model group after an attribute, an attribute after `anyAttribute`, or a second `anyAttribute` was accepted. The children are matched against an ordered slot model (one model group, unbounded attributes, one `anyAttribute`) by a forward-only walk; the check runs only in the `complexContent` context, where a model group is part of the model, so a `simpleContent` derivation (facets, no model group) keeps its own rules and no valid schema is rejected. XSTS: invalid schemas accepted 864 to 785, no other bucket raised (valid schemas rejected held at 72). diff --git a/Sources/Schema/SchemaContentOrder.swift b/Sources/Schema/SchemaContentOrder.swift index 3e12afe..105d9d0 100644 --- a/Sources/Schema/SchemaContentOrder.swift +++ b/Sources/Schema/SchemaContentOrder.swift @@ -29,6 +29,22 @@ extension PureXML.Schema.XSDParser { return errors } + /// Content-model order for a complexType's shorthand form (no `simpleContent`/ + /// `complexContent`), which shares the derivation's slot model: an optional model + /// group, then `attribute`/`attributeGroup`, then an optional `anyAttribute`. + /// `complexTypeContentErrors` already checks cardinality and the exclusivity of a + /// content spec with model groups/attributes; this adds the ordering (a model + /// group after an attribute, an attribute after `anyAttribute`, a second + /// `anyAttribute`). `annotation` and the content specs are not part of the slots. + static func complexTypeOrderErrors(_ complexType: XSDTree) -> [String] { + let names = PureXML.Schema.XSDNode.elementChildren(complexType) + .filter { $0.name?.namespaceURI == xsdNamespace } + .compactMap(PureXML.Schema.XSDNode.localName) + .filter { $0 != "annotation" && $0 != "simpleContent" && $0 != "complexContent" } + guard let reason = slotOrderViolation(names, slots: complexDerivationSlots) else { return [] } + return ["the content of a complexType must be a model group, then attributes, then anyAttribute: \(reason)"] + } + /// The ordered slots of a `complexContent` derivation: each lists its admitted /// names and how many children may fill it (nil is unbounded). private static let complexDerivationSlots: [(members: Set, max: Int?)] = [ diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index c6c0525..3cf4d0f 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -147,6 +147,7 @@ extension PureXML.Schema.XSDParser { } if local == "complexType" { errors += elementDeclsConsistentErrors(node) + errors += complexTypeOrderErrors(node) } if local == "complexContent" { errors += complexContentOrderErrors(node) diff --git a/Tests/SchemaStructureTests.swift b/Tests/SchemaStructureTests.swift index 206dfae..3a29001 100644 --- a/Tests/SchemaStructureTests.swift +++ b/Tests/SchemaStructureTests.swift @@ -272,4 +272,20 @@ struct SchemaStructureTests { """#) } + + @Test("a complexType's shorthand content must be ordered: model group, attributes, anyAttribute") + func test_complexTypeShorthandOrder() { + // A model group after an attribute, two anyAttributes, an attribute after anyAttribute (ctB). + #expect(rejects(#""#)) + #expect(rejects(#""#)) + #expect(rejects(#""#)) + // A well-ordered shorthand complexType compiles. + #expect(!rejects(#""" + + + + + + """#)) + } } diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index cdb0bef..ce21b15 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 = 72 - private let knownSchemaInvalidAccepted = 687 + private let knownSchemaInvalidAccepted = 664 private let knownInstanceValidRejected = 214 private let knownInstanceInvalidAccepted = 165 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index b7744fe..e55874c 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 | we rarely reject a good schema | -| **invalid-schemas-accepted** | **2461 -> 687** | we rarely catch a bad one (facet, id, structural content-model, UPA determinism, complexContent-derivation order, identity-constraint XPath-subset, and wildcard-namespace validity landed) | +| **invalid-schemas-accepted** | **2461 -> 664** | we rarely catch a bad one (facet, id, structural content-model, UPA determinism, complexContent + complexType content order, identity-constraint XPath-subset, and wildcard-namespace validity landed) | | valid-instances-rejected | 233 | (instance side, separately tracked) | | invalid-instances-accepted | 165 | |