fix(schema): bound attribute-group reference depth so a deep chain cannot overflow the stack (#375)
A long <xs:attributeGroup ref> 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.
This commit is contained in:
parent
8acf8ffff6
commit
fd61d4466b
@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- A long `<xs:attributeGroup ref>` 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 `<xs:group ref>` 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 `<xs:group>` 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).
|
||||
|
||||
@ -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<String> = []) -> [PureXML.Schema.AttributeUse] {
|
||||
// An `<xs:attributeGroup ref>` 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) {
|
||||
|
||||
@ -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 `<xs:group ref>`
|
||||
/// 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
|
||||
/// `<xs:group ref>` chain (`g0 = (e0, g1)`, ...), a `complexContent` derivation chain
|
||||
/// (`B0` extends `B1` extends ...), and an `<xs:attributeGroup ref>` 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
|
||||
? "<xs:attributeGroup ref=\"ag\(index + 1)\"/>"
|
||||
: "<xs:attribute name=\"a\" type=\"xs:string\"/>"
|
||||
parts.append("<xs:attributeGroup name=\"ag\(index)\">\(inner)</xs:attributeGroup>")
|
||||
}
|
||||
return "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\(parts.joined())"
|
||||
+ "<xs:element name=\"doc\"><xs:complexType>"
|
||||
+ "<xs:attributeGroup ref=\"ag0\"/></xs:complexType></xs:element></xs:schema>"
|
||||
}
|
||||
|
||||
private func groupChain(depth: Int) -> String {
|
||||
var parts: [String] = []
|
||||
for index in 0 ..< depth {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user