From fd61d4466b5984a216bf3f70d0a64d6d01ede342 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 29 Jun 2026 03:58:18 +0200 Subject: [PATCH] fix(schema): bound attribute-group reference depth so a deep chain cannot overflow the stack (#375) A long chain (ag0 refs ag1 refs ag2 ...) is flat in the document, so flattening a complex type's attributes through it recursed once per link: the stack overflowed past ~6000 links and it was near-cubic before that (~17s at 3000). attributeUses already threads a visited cycle-guard set whose count is the chain depth, so bound it against maximumContentModelExpansionDepth (512): a chain past the bound is rejected with a located error rather than crashed. Behaviour-preserving within the bound, which no realistic schema or the XSTS corpus approaches; verified conformance-neutral over the full W3C XSTS corpus and the full suite. Residual: many distinct over-bound chains still reject in O(n^2) per group (attribute flattening re-done per group); a memo is the linear follow-up. --- CHANGELOG.md | 2 + Sources/Schema/XSDAttributeParser.swift | 12 ++++++ .../SchemaContentModelDepthSafetyTests.swift | 39 +++++++++++++++---- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a45c5..0475e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A long `` chain (`ag0` referencing `ag1` referencing `ag2`, and so on) no longer overflows the stack or hangs the compiler. Such a chain is flat in the schema document, so flattening a complex type's attributes through it recursed once per link, overflowing the stack past about six thousand links and running near-cubic before that (about 17 seconds at three thousand links). The attribute-group reference depth is now bounded by `maximumContentModelExpansionDepth` (512, the same bound the content-model expansion uses), since the cycle-guard `visited` set already counts the chain depth: a chain past the bound is rejected with a located error (`attribute group reference nesting exceeds the maximum depth of 512`) rather than crashed. Behaviour-preserving for every schema within the bound, which no realistic schema or the XSTS corpus approaches; verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged) and by the full suite. Disclosed residual: a schema with very many distinct over-bound attribute-group chains still rejects in time quadratic in the bounded depth per group, because the attribute flattening is re-done per group; memoizing it, as the `simpleContent` resolution now is, is the linear follow-up. + - The schema compiler no longer overflows the stack on a content model whose expanded depth is unbounded. Two constructs are flat in the schema document, so they escape the parser's element-nesting `maxDepth` (256) yet make a content model arbitrarily deep once compiled: a `` chain (the group definitions are siblings, not nested) and a `complexContent` derivation chain (`B0` extends `B1` extends `B2`, and so on, the type definitions also siblings). The particle compiler inlines each group reference and composes each derivation once per link, so a long chain recursed once per link until the stack overflowed while compiling a legal, if hostile, schema, a keystroke-time interactive-safety crash. The expanded depth is now bounded by a single counter (`XSDContext.expansionDepth`, incremented at each compositor level, group-reference expansion, and derivation step) against `maximumContentModelExpansionDepth` (512, twice the element-nesting limit: room for a maximally nested model plus as much again for expansion). A model past the bound is rejected with a located error (`content model nesting ... exceeds the maximum depth of 512`, or the derivation form), not crashed: a bound, not a band-aid. The bound is on total expanded depth, so it covers both a long chain of shallow links and a short chain of internally deep ones. Together with the determinism-check fix below, this closes the group-reference-chain crash on every compiler pass that recursed on the chain. Verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged; no valid schema newly rejected, the corpus's content models nesting far below the bound), the libxml2 determinism differential unchanged, and the full suite. An adversarial over-rejection critic confirmed the counter lives in a value-type context whose copies make it track depth, not width, so a wide-but-shallow model (hundreds of sibling particles) is never falsely rejected, and that the rejection is harvested into the schema's errors rather than silently truncating the model. - A deeply nested chain of `` references (`g0` referencing `g1` referencing `g2`, and so on) no longer overflows the stack in the Unique Particle Attribution determinism check. Such a chain is flat in the schema document, the group definitions being siblings rather than nested, so it is not bounded by the parser's `maxDepth`; the compositional check summarised each group lazily by recursing through the reference at its use site, so a long chain recursed once per link and overflowed the stack while compiling a legal (if hostile) schema, a keystroke-time interactive-safety crash on the primary platform. Group summaries form an acyclic graph (a circular group reference is rejected elsewhere), so they are now precomputed bottom-up in dependency order: every reachable group is summarised once, in an order where each group's own references are already cached, so the walk's group lookup is always a cache hit and recursion is bounded by a single group's model nesting (which `maxDepth` bounds), not by the chain length. A group summary is context-independent, so this only reorders the work; the set of decision sets it produces is identical and the accept/reject verdict does not depend on their order. Verified verdict-equivalent over the full W3C XSTS corpus in release (all four buckets unchanged: `valid-schemas-rejected` and `valid-instances-rejected` stay 0, `invalid-schemas-accepted` 8, `invalid-instances-accepted` 14), the generated-schema determinism differential against libxml2 unchanged (zero false positives, the same three named false negatives), and the full 1816-test suite. The particle-compiler pass that inlines a group reference, which this left recursing on the same chain, is now bounded as well (see the content-model expansion-depth entry above). diff --git a/Sources/Schema/XSDAttributeParser.swift b/Sources/Schema/XSDAttributeParser.swift index ea86c93..d1cce9b 100644 --- a/Sources/Schema/XSDAttributeParser.swift +++ b/Sources/Schema/XSDAttributeParser.swift @@ -3,6 +3,18 @@ extension PureXML.Schema.XSDParser { /// `attributeGroup` references it nests, flattened. The `visited` set guards /// against attribute-group reference cycles. static func attributeUses(under node: XSDTree, _ context: PureXML.Schema.XSDContext, visited: Set = []) -> [PureXML.Schema.AttributeUse] { + // An `` chain is flat in the document, so it escapes the + // parser's element-nesting `maxDepth`; flattening it recurses once per link, which + // overflows the stack on a long chain and is super-linear before that. `visited` + // counts the chain depth, so bound it the same way the content-model expansion is + // bounded, rejecting a pathological chain with a located error rather than crashing. + guard visited.count <= maximumContentModelExpansionDepth else { + context.diagnostics.report( + "attribute group reference nesting exceeds the maximum depth of \(maximumContentModelExpansionDepth)", + at: node, + ) + return [] + } var uses: [PureXML.Schema.AttributeUse] = [] for child in PureXML.Schema.XSDNode.elementChildren(node) { switch PureXML.Schema.XSDNode.localName(child) { diff --git a/Tests/SchemaContentModelDepthSafetyTests.swift b/Tests/SchemaContentModelDepthSafetyTests.swift index 6f19e90..975e6bc 100644 --- a/Tests/SchemaContentModelDepthSafetyTests.swift +++ b/Tests/SchemaContentModelDepthSafetyTests.swift @@ -3,14 +3,15 @@ import Testing @testable import PureXML - /// Two flat-in-the-document constructs escape the parser's element-nesting `maxDepth` - /// and so let a content model's EXPANDED depth grow without bound: a `` - /// chain (`g0 = (e0, g1)`, `g1 = (e1, g2)`, ...) and a `complexContent` derivation chain - /// (`B0` extends `B1` extends ...). The particle compiler inlines a group reference and - /// composes a derivation once per link, so a long chain recursed until the stack - /// overflowed while compiling a legal (if hostile) schema. Both are now bounded by one + /// Several constructs are flat in the document, so they escape the parser's + /// element-nesting `maxDepth` and let a compiled depth grow without bound: a + /// `` chain (`g0 = (e0, g1)`, ...), a `complexContent` derivation chain + /// (`B0` extends `B1` extends ...), and an `` chain (`ag0` refs + /// `ag1` refs ...). The compiler inlines a group reference, composes a derivation, and + /// flattens an attribute group once per link, so a long chain recursed until the stack + /// overflowed while compiling a legal (if hostile) schema. Each is now bounded by /// `maximumContentModelExpansionDepth` (512): within it compiles, past it is rejected - /// with a LOCATED error, neither crashes. + /// with a LOCATED error, none crashes. /// /// Runs on an 8 MB stack, the native main-thread budget: reaching the bound is hundreds /// of levels deep (a couple of MB), more than swift-testing's small Task stack, and WASI @@ -39,6 +40,17 @@ assertRejectedForDepth(compile(derivationChain(depth: 600))) } + @Test("an attribute-group reference chain within the depth bound compiles") + func test_attributeGroupChainWithinBoundCompiles() { + #expect(compile(attributeGroupChain(depth: 400)).compiled) + } + + @Test("an attribute-group reference chain past the bound is rejected with a located error, not a crash") + func test_attributeGroupChainOverBoundRejectsLocated() { + // Past the bound and past the depth at which flattening used to overflow the stack. + assertRejectedForDepth(compile(attributeGroupChain(depth: 600))) + } + // MARK: Helpers private func assertRejectedForDepth(_ outcome: Outcome) { @@ -48,6 +60,19 @@ #expect(!(depthFinding?.codingPath.isEmpty ?? true)) // located: an IDE can underline it } + private func attributeGroupChain(depth: Int) -> String { + var parts: [String] = [] + for index in 0 ..< depth { + let inner = index < depth - 1 + ? "" + : "" + parts.append("\(inner)") + } + return "\(parts.joined())" + + "" + + "" + } + private func groupChain(depth: Int) -> String { var parts: [String] = [] for index in 0 ..< depth {