fix(parsing): bound nesting depth so pathological input errors instead of crashing
This commit is contained in:
parent
5d76d184cb
commit
64a171bff4
11
CHANGELOG.md
11
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
78
Tests/ParsingNestingLimitTests.swift
Normal file
78
Tests/ParsingNestingLimitTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user