fix(parsing): bound nesting depth so pathological input errors instead of crashing

This commit is contained in:
Mihaela Mihaljevic 2026-06-23 09:29:45 +02:00
parent 5d76d184cb
commit 64a171bff4
4 changed files with 123 additions and 2 deletions

View File

@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.1.4] - 2026-06-23
### Fixed
- Reject documents nested beyond a fixed depth (`maxNestingDepth`) with a
`nestingTooDeep` parse error instead of overflowing the recursive-descent
parser's stack. A pathologically nested input (for example hundreds of nested
`[[[...]]]` or deeply indented mappings) previously crashed the process; it now
fails cleanly. The limit is well above the nesting of real-world specs, which
are unaffected.
## [0.1.3] - 2026-06-23
### Fixed

View File

@ -20,6 +20,7 @@ public extension PureYAML.Parsing {
case undefinedAlias(anchor: String, line: Int, column: Int)
case unexpectedEvent(expected: String, actual: String, line: Int, column: Int)
case unexpectedToken(expected: String, actual: String, line: Int, column: Int)
case nestingTooDeep(line: Int, column: Int)
public var description: String {
switch self {
@ -61,6 +62,8 @@ public extension PureYAML.Parsing {
"expected \(expected) at line \(line), column \(column), found \(actual)"
case let .unexpectedToken(expected, actual, line, column):
"expected \(expected) at line \(line), column \(column), found \(actual)"
case let .nestingTooDeep(line, column):
"nesting is too deep at line \(line), column \(column)"
}
}
@ -104,6 +107,8 @@ public extension PureYAML.Parsing {
"unexpectedEvent"
case .unexpectedToken:
"unexpectedToken"
case .nestingTooDeep:
"nestingTooDeep"
}
}
@ -129,7 +134,8 @@ public extension PureYAML.Parsing {
let .invalidMergeValue(line, _),
let .undefinedAlias(_, line, _),
let .unexpectedEvent(_, _, line, _),
let .unexpectedToken(_, _, line, _):
let .unexpectedToken(_, _, line, _),
let .nestingTooDeep(line, _):
line
}
}
@ -155,7 +161,8 @@ public extension PureYAML.Parsing {
let .invalidMergeValue(_, column),
let .undefinedAlias(_, _, column),
let .unexpectedEvent(_, _, _, column),
let .unexpectedToken(_, _, _, column):
let .unexpectedToken(_, _, _, column),
let .nestingTooDeep(_, column):
column
}
}

View File

@ -5,6 +5,17 @@ extension PureYAML.Parsing {
var events: [Event]?
var eventSink: ((Event) throws -> Void)?
/// Current node recursion depth, tracked so deeply nested input fails
/// with a diagnostic instead of overflowing the stack.
var depth = 0
/// Maximum node nesting the recursive-descent parser will follow.
/// Real-world specs nest well under this; the bound exists so a
/// pathologically nested document (e.g. hundreds of `[[[...]]]`)
/// throws ``ParseError/nestingTooDeep(line:column:)`` rather than
/// crashing the process.
static let maxNestingDepth = 64
init(
tokens: [Token],
scalarParser: Parser,
@ -156,9 +167,23 @@ extension PureYAML.Parsing.TokenEventParser {
return try parseNode(properties: properties)
}
/// Reject documents nested past ``maxNestingDepth`` with a diagnostic so a
/// pathologically nested input fails cleanly instead of overflowing the
/// recursive-descent stack.
func enforceNestingLimit(properties: PureYAML.Parsing.NodeProperties) throws {
guard depth > Self.maxNestingDepth else {
return
}
let mark = properties.mark ?? cursor.current?.mark ?? cursor.previous?.endMark ?? .start
throw PureYAML.Parsing.ParseError.nestingTooDeep(line: mark.line, column: mark.column)
}
mutating func parseNode(
properties initialProperties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
depth += 1
defer { depth -= 1 }
try enforceNestingLimit(properties: initialProperties)
var properties = initialProperties
try properties.mergeUnset(from: consumeProperties())

View File

@ -0,0 +1,78 @@
import Foundation
@testable import PureYAML
import Testing
@Suite("Parsing Nesting Limit")
struct ParsingNestingLimitTests {
/// Result of parsing on a dedicated, generously sized stack.
///
/// Driving the parser to its nesting limit takes more stack than a default
/// test-runner thread may have, so these tests run the parse on their own
/// thread with a large stack. That isolates the assertion (does the parser
/// reject the input with a diagnostic?) from the runner's thread-stack size.
private final class ParseOutcome: @unchecked Sendable {
var value: PureYAML.Model.Value?
var parseError: PureYAML.Parsing.ParseError?
var otherError: (any Error)?
}
private func parseOnGenerousStack(_ yaml: String) -> ParseOutcome {
let outcome = ParseOutcome()
let done = DispatchSemaphore(value: 0)
let thread = Thread {
do {
outcome.value = try PureYAML.parse(yaml)
} catch let error as PureYAML.Parsing.ParseError {
outcome.parseError = error
} catch {
outcome.otherError = error
}
done.signal()
}
thread.stackSize = 16 * 1024 * 1024
thread.start()
done.wait()
return outcome
}
@Test("Deeply nested flow collections throw nestingTooDeep instead of overflowing the stack")
func test_deepFlowThrowsCleanly() {
let depth = 400
let yaml = "x: " + String(repeating: "[", count: depth) + String(repeating: "]", count: depth)
let outcome = parseOnGenerousStack(yaml)
#expect(outcome.otherError == nil)
#expect(outcome.value == nil)
#expect(outcome.parseError?.diagnosticCode == "nestingTooDeep")
}
@Test("Deeply nested block mappings throw nestingTooDeep instead of overflowing the stack")
func test_deepBlockThrowsCleanly() {
var yaml = "deep:\n"
for level in 0 ..< 400 {
yaml += String(repeating: " ", count: level + 1) + "k:\n"
}
yaml += String(repeating: " ", count: 401) + "v: 1\n"
let outcome = parseOnGenerousStack(yaml)
#expect(outcome.otherError == nil)
#expect(outcome.parseError?.diagnosticCode == "nestingTooDeep")
}
@Test("Nesting within real-world depth still parses to the expected structure")
func test_moderateNestingParses() throws {
// Real specs in the corpus nest no deeper than the high 20s; this stays
// well inside the limit and must round-trip.
let depth = 24
let yaml = "x: " + String(repeating: "[", count: depth) + "1" + String(repeating: "]", count: depth)
let outcome = parseOnGenerousStack(yaml)
#expect(outcome.parseError == nil)
#expect(outcome.otherError == nil)
var current = try #require(outcome.value).mapping?["x"]
var measured = 0
while case let .sequence(items)? = current, let first = items.first {
measured += 1
current = first
}
#expect(measured == depth)
#expect(current == .int(1))
}
}