fix(schema): reject circular type derivation (ct-props-correct.3 / st-props-correct.2)

A complex or simple type may not derive, transitively through its base, from
itself. Such schemas were accepted. The check walks the base chain built from
each type's complexContent/simpleContent extension/restriction base (and a
simpleType's restriction base), following an edge only when the base resolves to
the schema's own target namespace.

Three exceptions keep it from over-rejecting: element content that recurses (an
element of the type's own type) is not a derivation and is not followed; a type
redefined in xs:redefine legitimately names itself as its base, so redefined
types end the chain; and the ur-types (anyType/anySimpleType/anyAtomicType,
which the schema-for-schemas bootstraps with self-referential definitions) end
the chain. The target-namespace gate prevents a local type whose base is a
same-local-name type in another namespace from being mistaken for a self-cycle
(found by an adversarial critic; the engine resolves by local name).

XSTS: invalid schemas accepted 519 to 516 (addB101, elemM003, xsd007.e now
rejected), no other bucket raised. Union/list and genuine cross-namespace
derivation cycles are a disclosed under-rejection.
This commit is contained in:
Mihaela Mihaljevic 2026-06-15 01:26:37 +02:00
parent 81be470512
commit 96ec9cd1e0
7 changed files with 177 additions and 5 deletions

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Circular type derivation is now rejected at compile time (XSD 1.0 `ct-props-correct.3` / `st-props-correct.2`, #168). A complex or simple type may not derive, transitively through its base, from itself; previously such a schema was accepted. Only the base chain is followed, so a type whose element content recurses (an element of its own type, the normal recursive-data-structure case) is correctly permitted, and a type redefined in `xs:redefine` (whose base legitimately names its former self) and the ur-types (`anyType`/`anySimpleType`/`anyAtomicType`, the roots the schema-for-schemas bootstraps) end the chain rather than appear as cycles. The base is followed only when it resolves to the schema's own target namespace, so a type whose base is a same-local-name type in another namespace is not mistaken for a self-cycle. XSTS: invalid schemas accepted 519 to 516, no other bucket raised. Union/list (`itemType`/`memberTypes`) and genuine cross-namespace derivation cycles are a disclosed under-rejection (the engine resolves by local name).
- Particle Valid (Restriction) now implements the W3C MapAndSum rule for a sequence restricting a choice (#165). When a derived sequence restricts a base choice, the spec (XSD 1.0 §3.9.6) requires that each derived particle restrict some base branch AND that the derived sequence's occurrence count times its number of particles lie within the base choice's occurrence range. PureXML previously applied the same-compositor occurrence subsumption (comparing the sequence's own range directly to the choice's), which both rejected a valid restriction (`sequence{2,4}(e1,e2)` of `choice{3,9}(e1,e2)`, whose 4-to-8 elements fit the choice's 3-to-9) and accepted an invalid one (a two-member sequence restricting a single-occurrence choice, which the count-product `2 > 1` now rejects). The count-product is the spec's measure, matched to the reference implementation. XSTS: valid schemas rejected 2 to 1, invalid schemas accepted 520 to 519, no other bucket raised. The one remaining valid-rejected case (`particlesZ001`) is a documented spec ambiguity (its own test notes the RecurseAsIfGroup rule "is ambiguous"; the strict reading the reference follows rejects it), recorded in `docs/xsts-deviations.md`.
- Two more Particle Valid (Restriction) over-rejections closed (#148). A derived particle that can never contribute a child (its own `maxOccurs="0"`, or a group whose every member is content-free) accepts only the empty sequence, so it is now recognized as a valid restriction of any emptiable base before the structural cases run; previously a `maxOccurs="0"` group whose members carried their own non-trivial occurrences was wrongly recursed into and rejected (`particlesW006`). And substitution-group expansion now lists members in declaration order: the closure walk used a `popLast()` stack that reversed them, so a reference to a head expanded to `choice(head, m2, m1)` and an order-preserving RecurseLax restriction by `choice(m1, m2)` could not map (`elemZ027a`). The choice/choice restriction stays order-preserving, as the W3C suite requires (a `choice(b|a)` is not a valid restriction of `choice(a|b)`, `particlesT002`); only the expansion order is corrected. The expansion is shared with instance validation and the determinism check, both order-neutral, so neither is affected. XSTS: valid schemas rejected 4 to 2, no other bucket raised. (The pinned XSTS baselines, which the opt-in suite never asserts under a plain `swift test`, were also corrected to the measured 2/520/180/160.)

View File

@ -38,4 +38,16 @@ extension PureXML.Schema.XSDParser {
private static func unprefixedID(_ node: XSDTree) -> String? {
node.attributes.first { $0.name.prefix == nil && $0.name.localName == "id" }?.value
}
/// All compile-time schema-consistency findings, collected together so they are
/// reported in one pass: `id` validity, schema-for-schemas structure, global
/// component-name uniqueness, content-model determinism (UPA), and circular
/// type derivation. The explicit return type keeps the type-checker from
/// inferring a long concatenation.
static func consistencyErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ containers: [XSDTree]) -> [String] {
let structural = idAttributeErrors(schema) + structureErrors(schema) + componentNameErrors(schema)
let determinism = PureXML.Schema.ContentModelDeterminism.violations(in: schema, context: context)
let cycles = derivationCycleErrors(containers, context.namespaceBindings, context.targetNamespace)
return structural + determinism + cycles
}
}

View File

@ -121,6 +121,86 @@ extension PureXML.Schema.XSDParser {
return methods
}
/// Rejects circular type derivation (XSD 1.0 `ct-props-correct.3` /
/// `st-props-correct.2`): a complex or simple type may not derive, transitively
/// through its `{base type definition}`, from itself. Only the base chain is
/// followed, so a type whose *element content* recurses (an element of its own
/// type, the normal recursive-data-structure case) is correctly permitted.
///
/// A type redefined in `xs:redefine` legitimately names itself as its base (the
/// redefinition derives from its former self), so redefined types are treated as
/// chain ends rather than self-cycles, matching the suite's "circular ref is
/// allowed if parent is redefine".
static func derivationCycleErrors(_ containers: [XSDTree], _ bindings: [String: String], _ target: String?) -> [String] {
let redefined = redefinedTypeNames(containers)
var graph: [String: String] = [:]
for container in containers {
for kind in ["complexType", "simpleType"] {
for type in descendants(container, named: kind) {
guard let name = XSDDerivNode.attribute(type, "name"), let base = rawBase(of: type) else { continue }
// Follow a base only when it resolves to this schema's own target
// namespace. A foreign-namespace base is resolved separately, so
// following it by local name would report a false self-cycle when the
// local names collide (a `local:X` extending an imported `other:X`).
guard XSDDerivNode.referenceNamespace(base, bindings) == target else { continue }
graph[name] = XSDDerivNode.stripPrefix(base)
}
}
}
return graph.keys.sorted().compactMap { name in
guard !redefined.contains(name), derivesFromItself(name, graph, redefined) else { return nil }
return "type '\(name)' must not be derived from itself"
}
}
/// The raw (prefixed) base a type declares: a complex type's
/// `complexContent`/`simpleContent` extension/restriction base, or a simple
/// type's restriction base.
private static func rawBase(of type: XSDTree) -> String? {
if let derivation = XSDDerivNode.firstChild(type, named: "complexContent") ?? XSDDerivNode.firstChild(type, named: "simpleContent") {
let node = XSDDerivNode.firstChild(derivation, named: "extension") ?? XSDDerivNode.firstChild(derivation, named: "restriction")
return node.flatMap { XSDDerivNode.attribute($0, "base") }
}
return XSDDerivNode.firstChild(type, named: "restriction").flatMap { XSDDerivNode.attribute($0, "base") }
}
/// Whether following `start`'s base chain returns to `start`. A redefined type
/// is a chain end (its self-naming base is the redefinition, not a cycle); a
/// base outside the graph (a built-in, an unresolved or foreign type) ends the
/// chain. A cycle not passing through `start` ends the walk without a match (it
/// is reported when its own members are the start).
private static func derivesFromItself(_ start: String, _ graph: [String: String], _ redefined: Set<String>) -> Bool {
guard !urTypeNames.contains(start) else { return false }
var current = start
var seen: Set<String> = []
while !redefined.contains(current), !urTypeNames.contains(current), let base = graph[current] {
if base == start { return true }
guard seen.insert(base).inserted else { return false }
current = base
}
return false
}
/// The roots of the type hierarchy. They terminate every derivation chain, so
/// they are never part of a cycle. The schema-for-schemas declares them in the
/// XSD namespace with bootstrap definitions that name each other; following
/// those would report a spurious `anySimpleType`/`string` cycle.
private static let urTypeNames: Set<String> = ["anyType", "anySimpleType", "anyAtomicType"]
/// The names of types redefined in an `xs:redefine`, whose `base` legitimately
/// names themselves.
private static func redefinedTypeNames(_ containers: [XSDTree]) -> Set<String> {
var names: Set<String> = []
for container in containers where XSDDerivNode.localName(container) == "redefine" {
for kind in ["complexType", "simpleType"] {
for type in XSDDerivNode.children(container, named: kind) {
if let name = XSDDerivNode.attribute(type, "name") { names.insert(name) }
}
}
}
return names
}
/// Throws when a type inside an `xs:redefine` does not derive from itself: a
/// redefinition's `base` must name the type being redefined.
static func checkRedefine(_ containers: [XSDTree]) throws {

View File

@ -34,9 +34,7 @@ extension PureXML.Schema {
context.namespaceBindings = XSDNode.namespaceBindings(of: schema)
context.complexTypeNodes = indexByName(allChildren(containers, named: "complexType"))
context.globalAttributes = indexByName(allChildren(containers, named: "attribute"))
let consistencyErrors = idAttributeErrors(schema) + structureErrors(schema)
+ componentNameErrors(schema) + ContentModelDeterminism.violations(in: schema, context: context)
for error in consistencyErrors {
for error in consistencyErrors(schema, context, containers) {
context.diagnostics.report(error)
}
var types = namedTypes(containers, into: &context)

View File

@ -0,0 +1,80 @@
@testable import PureXML
import Testing
@Suite("XSD circular type derivation")
struct SchemaCircularDerivationTests {
private let header = "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">"
private func compiles(_ body: String, loader: @escaping (String) -> String? = { _ in nil }) -> Bool {
(try? PureXML.Schema.Document(header + body + "</xs:schema>", schemaLoader: loader)) != nil
}
@Test("A type derived from itself is rejected (ct-props-correct.3 / st-props-correct.2)")
func test_selfDerivationRejected() {
// A complex type extending itself (addB101).
#expect(!compiles(
"<xs:complexType name=\"A\"><xs:complexContent><xs:extension base=\"A\"><xs:sequence/></xs:extension></xs:complexContent></xs:complexType>",
))
// A complex type restricting itself.
#expect(!compiles(
"<xs:complexType name=\"A\"><xs:complexContent><xs:restriction base=\"A\"><xs:sequence/></xs:restriction></xs:complexContent></xs:complexType>",
))
// A mutual derivation cycle A -> B -> A.
#expect(!compiles(
"<xs:complexType name=\"A\"><xs:complexContent><xs:extension base=\"B\"><xs:sequence/></xs:extension></xs:complexContent></xs:complexType>"
+ "<xs:complexType name=\"B\"><xs:complexContent><xs:extension base=\"A\"><xs:sequence/></xs:extension></xs:complexContent></xs:complexType>",
))
// A simpleType restriction cycle s0 -> s1 -> s0.
#expect(!compiles(
"<xs:simpleType name=\"s0\"><xs:restriction base=\"s1\"/></xs:simpleType>"
+ "<xs:simpleType name=\"s1\"><xs:restriction base=\"s0\"/></xs:simpleType>",
))
}
@Test("Recursive element content and normal derivation are not cycles")
func test_recursionAndNormalDerivationAccepted() {
// A type containing an element of its own type is a recursive data structure,
// not a derivation cycle: only the base chain is a cycle, element content is
// not. (Must compile.)
#expect(compiles(
"<xs:complexType name=\"node\"><xs:sequence><xs:element name=\"child\" type=\"node\" minOccurs=\"0\"/></xs:sequence></xs:complexType>"
+ "<xs:element name=\"root\" type=\"node\"/>",
))
// Deriving from a ur-type terminates the chain (the schema-for-schemas case).
#expect(compiles(
"<xs:complexType name=\"A\"><xs:complexContent><xs:extension base=\"xs:anyType\"><xs:sequence/></xs:extension></xs:complexContent></xs:complexType>",
))
// A normal restriction chain A -> base -> xs:string.
#expect(compiles(
"<xs:simpleType name=\"a\"><xs:restriction base=\"b\"/></xs:simpleType>"
+ "<xs:simpleType name=\"b\"><xs:restriction base=\"xs:string\"/></xs:simpleType>",
))
}
@Test("A type whose base is a same-local-name type in another namespace is not a cycle")
func test_crossNamespaceSameLocalNameAccepted() {
// local:X extends the entirely different foreign:X. Resolving by local name
// would falsely report a self-cycle; the namespace-aware walk follows only
// a base in this schema's own target namespace, so the foreign base ends the
// chain. (Must compile.)
let schema = "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
+ " xmlns:ns=\"urn:foreign\" targetNamespace=\"urn:local\" xmlns=\"urn:local\">"
+ "<xs:import namespace=\"urn:foreign\"/>"
+ "<xs:complexType name=\"X\"><xs:complexContent><xs:extension base=\"ns:X\">"
+ "<xs:sequence/></xs:extension></xs:complexContent></xs:complexType></xs:schema>"
#expect((try? PureXML.Schema.Document(schema)) != nil)
}
@Test("A type redefining itself is allowed (the base names its former self)")
func test_redefineSelfDerivationAccepted() {
let base = "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">"
+ "<xs:complexType name=\"T\"><xs:sequence><xs:element name=\"x\" type=\"xs:string\"/></xs:sequence></xs:complexType>"
+ "</xs:schema>"
let body = "<xs:redefine schemaLocation=\"base.xsd\">"
+ "<xs:complexType name=\"T\"><xs:complexContent><xs:restriction base=\"T\">"
+ "<xs:sequence><xs:element name=\"x\" type=\"xs:string\"/></xs:sequence>"
+ "</xs:restriction></xs:complexContent></xs:complexType></xs:redefine>"
+ "<xs:element name=\"r\" type=\"T\"/>"
#expect(compiles(body, loader: { $0 == "base.xsd" ? base : 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 = 519
private let knownSchemaInvalidAccepted = 516
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 -> 519** | 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 landed). |
| **invalid-schemas-accepted** | **2461 -> 516** | 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 landed). |
| valid-instances-rejected | **233 -> 180** | (instance side; #164's element-ref namespace fix is shared with instance validation, -34) |
| invalid-instances-accepted | 160 | |