#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 /// `` 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, 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 ? "" : "" parts.append("\(inner)") } return "\(parts.joined())" + "" + "" } private func groupChain(depth: Int) -> String { var parts: [String] = [] for index in 0 ..< depth { let next = index < depth - 1 ? "" : "" parts.append("" + "\(next)") } return "\(parts.joined())" + "" + "" } private func derivationChain(depth: Int) -> String { var parts: [String] = [] for index in 0 ..< depth { if index < depth - 1 { parts.append("" + "" + "" + "") } else { parts.append("" + "") } } return "\(parts.joined())" + "" } 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