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 and a complexContent derivation chain (the definitions are siblings). The particle compiler inlines each group reference and composes each derivation once per link, overflowing the stack on a long chain. The expanded depth is now bounded by one counter (XSDContext.expansionDepth, incremented at each compositor level, group-reference expansion, and derivation step) against maximumContentModelExpansionDepth (512); a model past the bound is rejected with a located error, not crashed. The counter lives in a value-type context so it tracks depth not width (no width false positive), and the rejection is harvested into the schema's errors (no silent truncation). Verified conformance-neutral over the full W3C XSTS corpus (all four buckets unchanged), the libxml2 determinism differential unchanged, and the full suite; an adversarial critic found the derivation-chain gap, which this fixes with the same counter.
124 lines
5.9 KiB
Swift
124 lines
5.9 KiB
Swift
#if !os(WASI)
|
|
import Foundation
|
|
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
|
|
/// `maximumContentModelExpansionDepth` (512): within it compiles, past it is rejected
|
|
/// with a LOCATED error, neither 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
|
|
/// cannot size a thread stack (its budget is a link-time setting), so the cases are gated
|
|
/// off there.
|
|
@Suite("Schema content-model expansion depth safety")
|
|
struct SchemaContentModelDepthSafetyTests {
|
|
@Test("a group-reference chain within the depth bound compiles")
|
|
func test_groupChainWithinBoundCompiles() {
|
|
#expect(compile(groupChain(depth: 400)).compiled)
|
|
}
|
|
|
|
@Test("a group-reference chain past the bound is rejected with a located error, not a crash")
|
|
func test_groupChainOverBoundRejectsLocated() {
|
|
// Past the bound and past the depth at which the unbounded compiler overflowed.
|
|
assertRejectedForDepth(compile(groupChain(depth: 2500)))
|
|
}
|
|
|
|
@Test("a complexContent derivation chain within the depth bound compiles")
|
|
func test_derivationChainWithinBoundCompiles() {
|
|
#expect(compile(derivationChain(depth: 10)).compiled)
|
|
}
|
|
|
|
@Test("a complexContent derivation chain past the bound is rejected with a located error, not a crash")
|
|
func test_derivationChainOverBoundRejectsLocated() {
|
|
assertRejectedForDepth(compile(derivationChain(depth: 600)))
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
private func assertRejectedForDepth(_ outcome: Outcome) {
|
|
#expect(!outcome.compiled)
|
|
let depthFinding = outcome.findings.first { $0.reason.contains("maximum") && $0.reason.contains("depth") }
|
|
#expect(depthFinding != nil) // rejected for depth, not some unrelated reason
|
|
#expect(!(depthFinding?.codingPath.isEmpty ?? true)) // located: an IDE can underline it
|
|
}
|
|
|
|
private func groupChain(depth: Int) -> String {
|
|
var parts: [String] = []
|
|
for index in 0 ..< depth {
|
|
let next = index < depth - 1 ? "<xs:group ref=\"g\(index + 1)\"/>" : ""
|
|
parts.append("<xs:group name=\"g\(index)\"><xs:sequence>"
|
|
+ "<xs:element name=\"e\(index)\" type=\"xs:string\"/>\(next)</xs:sequence></xs:group>")
|
|
}
|
|
return "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\(parts.joined())"
|
|
+ "<xs:element name=\"doc\"><xs:complexType><xs:sequence>"
|
|
+ "<xs:group ref=\"g0\"/></xs:sequence></xs:complexType></xs:element></xs:schema>"
|
|
}
|
|
|
|
private func derivationChain(depth: Int) -> String {
|
|
var parts: [String] = []
|
|
for index in 0 ..< depth {
|
|
if index < depth - 1 {
|
|
parts.append("<xs:complexType name=\"B\(index)\"><xs:complexContent>"
|
|
+ "<xs:extension base=\"B\(index + 1)\"><xs:sequence>"
|
|
+ "<xs:element name=\"e\(index)\" type=\"xs:string\"/></xs:sequence>"
|
|
+ "</xs:extension></xs:complexContent></xs:complexType>")
|
|
} else {
|
|
parts.append("<xs:complexType name=\"B\(index)\"><xs:sequence>"
|
|
+ "<xs:element name=\"leaf\" type=\"xs:string\"/></xs:sequence></xs:complexType>")
|
|
}
|
|
}
|
|
return "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\(parts.joined())"
|
|
+ "<xs:element name=\"doc\" type=\"B0\"/></xs:schema>"
|
|
}
|
|
|
|
private struct Outcome {
|
|
var compiled: Bool
|
|
var findings: [PureXML.Validation.ValidationError]
|
|
}
|
|
|
|
/// Compiles `schema` on a native-sized stack and reports whether it compiled and,
|
|
/// if not, the located findings.
|
|
private func compile(_ schema: String) -> Outcome {
|
|
let result = ResultBox()
|
|
runOnNativeStack {
|
|
do {
|
|
_ = try PureXML.Schema.Document(schema)
|
|
result.outcome = Outcome(compiled: true, findings: [])
|
|
} catch let error as PureXML.Schema.SchemaError {
|
|
if case let .inconsistent(findings) = error {
|
|
result.outcome = Outcome(compiled: false, findings: findings)
|
|
} else {
|
|
result.outcome = Outcome(compiled: false, findings: [])
|
|
}
|
|
} catch {
|
|
result.outcome = Outcome(compiled: false, findings: [])
|
|
}
|
|
}
|
|
return result.outcome
|
|
}
|
|
|
|
private final class ResultBox: @unchecked Sendable {
|
|
var outcome = Outcome(compiled: false, findings: [])
|
|
}
|
|
|
|
/// Runs `work` on a thread with the native main-thread stack budget and waits. The
|
|
/// semaphore orders the worker's write before the read, so the box is safe.
|
|
private func runOnNativeStack(_ work: @escaping @Sendable () -> Void) {
|
|
let done = DispatchSemaphore(value: 0)
|
|
let thread = Thread {
|
|
work()
|
|
done.signal()
|
|
}
|
|
thread.stackSize = 8 << 20
|
|
thread.start()
|
|
done.wait()
|
|
}
|
|
}
|
|
#endif
|