diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c7f388..02abd09 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index 5d89794..a68177c 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -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 } } diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index 5fd2a95..c6cb114 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -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()) diff --git a/Tests/ParsingNestingLimitTests.swift b/Tests/ParsingNestingLimitTests.swift new file mode 100644 index 0000000..0180df5 --- /dev/null +++ b/Tests/ParsingNestingLimitTests.swift @@ -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)) + } +}