From 96ec9cd1e01135d177ebf4daa776daf01c6427b4 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 15 Jun 2026 01:26:37 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + Sources/Schema/SchemaIDAttribute.swift | 12 ++++ Sources/Schema/XSDDerivation.swift | 80 +++++++++++++++++++++++ Sources/Schema/XSDParser.swift | 4 +- Tests/SchemaCircularDerivationTests.swift | 80 +++++++++++++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- docs/schema-validity-burndown.md | 2 +- 7 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 Tests/SchemaCircularDerivationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bd847f7..bde9c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.) diff --git a/Sources/Schema/SchemaIDAttribute.swift b/Sources/Schema/SchemaIDAttribute.swift index f0ebf2c..3a2055b 100644 --- a/Sources/Schema/SchemaIDAttribute.swift +++ b/Sources/Schema/SchemaIDAttribute.swift @@ -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 + } } diff --git a/Sources/Schema/XSDDerivation.swift b/Sources/Schema/XSDDerivation.swift index 9061c90..3c23459 100644 --- a/Sources/Schema/XSDDerivation.swift +++ b/Sources/Schema/XSDDerivation.swift @@ -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) -> Bool { + guard !urTypeNames.contains(start) else { return false } + var current = start + var seen: Set = [] + 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 = ["anyType", "anySimpleType", "anyAtomicType"] + + /// The names of types redefined in an `xs:redefine`, whose `base` legitimately + /// names themselves. + private static func redefinedTypeNames(_ containers: [XSDTree]) -> Set { + var names: Set = [] + 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 { diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index 4cb667a..52f0510 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -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) diff --git a/Tests/SchemaCircularDerivationTests.swift b/Tests/SchemaCircularDerivationTests.swift new file mode 100644 index 0000000..edaf04b --- /dev/null +++ b/Tests/SchemaCircularDerivationTests.swift @@ -0,0 +1,80 @@ +@testable import PureXML +import Testing + +@Suite("XSD circular type derivation") +struct SchemaCircularDerivationTests { + private let header = "" + + private func compiles(_ body: String, loader: @escaping (String) -> String? = { _ in nil }) -> Bool { + (try? PureXML.Schema.Document(header + body + "", 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( + "", + )) + // A complex type restricting itself. + #expect(!compiles( + "", + )) + // A mutual derivation cycle A -> B -> A. + #expect(!compiles( + "" + + "", + )) + // A simpleType restriction cycle s0 -> s1 -> s0. + #expect(!compiles( + "" + + "", + )) + } + + @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( + "" + + "", + )) + // Deriving from a ur-type terminates the chain (the schema-for-schemas case). + #expect(compiles( + "", + )) + // A normal restriction chain A -> base -> xs:string. + #expect(compiles( + "" + + "", + )) + } + + @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 = "" + + "" + + "" + + "" + #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 = "" + + "" + + "" + let body = "" + + "" + + "" + + "" + + "" + #expect(compiles(body, loader: { $0 == "base.xsd" ? base : nil })) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index cb8e759..3bc3d03 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 = 519 + private let knownSchemaInvalidAccepted = 516 private let knownInstanceValidRejected = 180 private let knownInstanceInvalidAccepted = 160 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index ee77090..d4fcbd6 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 -> 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 | |