PureXML/Tests/SchemaContentModelDepthSafetyTests.swift
Mihaela Mihaljevic 36b2a73637
chore(concurrency): justify every @unchecked Sendable / nonisolated(unsafe) (#387)
The rules-swift concurrency rule's acceptance check requires every `@unchecked
Sendable` and `nonisolated(unsafe)` to carry an adjacent comment explaining why
the race is acceptable; an unjustified one is a violation. An audit found six
escape hatches whose comment explained the type but not the race:

- The four XPath `FunctionTable` statics (`CoreFunctions`, `StringFunctions`,
  `NodeFunctions`, `Evaluator.library`): each is an immutable, build-once lookup
  table never mutated after initialization, so concurrent reads race on nothing;
  `FunctionTable` is un-`Sendable` only because it stores closures.
- `Validation.Validator`: immutable after `init` (every stored validation list is
  a `let`); the erased `AnyValidation` closures are simply not provably `Sendable`.
- The test `ResultBox`: written once by the worker thread before its semaphore
  signals and read only after the wait, so the write happens-before the read.

Comments only; no behaviour change. `LoaderBox` and `ElementStorage` were already
justified. swiftformat/swiftlint --strict and the full suite stay green.
2026-06-29 15:12:02 +02:00

152 lines
7.5 KiB
Swift

#if !os(WASI)
import Foundation
import Testing
@testable import PureXML
/// 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, 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
/// 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)))
}
@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) {
#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 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 {
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
}
/// @unchecked Sendable: written once by the worker thread before its semaphore
/// signals and read only after `runOnNativeStack`'s wait, so the worker's write
/// happens-before the read and there is no concurrent access.
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