PureYAML/Sources/Parsing/TokenEventParser.swift
Mihaela M 779ebe596d fix(parsing): reject an anchor or tag on an alias node
An alias is a bare reference and cannot carry node properties, so '&b *a'
(an anchor on an alias) and a tag on an alias are malformed. parseNode
ignored any pending anchor or tag when it reached an alias token, dropping
the property and accepting the document.

The alias case now rejects a pending anchor or tag, and moves to its own
parseAlias helper so parseNode stays within the complexity limit. A bare
alias and an anchor or tag on a real node are unaffected.

Measured against the official YAML test-suite, parse-level conformance
rises from 266/333 (79.9%) to 267/333 (80.2%) with no regression.
2026-06-27 21:11:10 +02:00

275 lines
11 KiB
Swift

extension PureYAML.Parsing {
struct TokenEventParser {
var cursor: TokenCursor
let scalarParser: Parser
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 parser will follow before failing with
/// ``ParseError/nestingTooDeep(line:column:)``.
///
/// This bound is the single safety invariant for deeply nested input,
/// and it is *necessary*, not merely convenient. A ``Model/Value`` is a
/// recursive tree, so composing it, emitting it, comparing it, hashing
/// it, and -- crucially -- deallocating it all recurse with the nesting
/// depth. Swift tears down a recursive value type recursively, and that
/// teardown cannot be made iterative cheaply, so the only way to keep
/// every operation within the call stack is to bound the depth of the
/// tree where it is built: here, while parsing.
///
/// Because every parsing entry point routes through ``parseNode``, this
/// one bound caps the depth of any parsed tree, which transitively keeps
/// the parser, the event composers, the emitter, equality, hashing, and
/// deallocation all well inside their limits. Measured on the main
/// thread's 8 MB stack: the parser overflows near depth 225 and the
/// tree operations near depth 50,000, while real-world specs nest below
/// ~40. So 64 accepts every realistic document yet rejects pathological
/// input -- for example a 100 KB `[[[...]]]` -- cleanly and early.
///
/// The bound cannot simply be raised: above ~225 the recursive parser
/// itself overflows before the guard fires (lifting it would require an
/// iterative parser), and a tree past ~5,000 deep crashes on `deinit`
/// regardless, so a higher limit would trade a parse-time failure for an
/// un-fixable teardown crash. 64 is the largest value that is both safe
/// here and sufficient for real input.
///
/// Caveat: a tree built *without* the parser -- for example by encoding
/// a pathologically deep `Codable` value -- bypasses this bound, and
/// operations on such a tree are O(depth) in stack like any recursive
/// value type. Keeping those shallow is the caller's responsibility, the
/// same way the standard library cannot prevent unbounded recursion in
/// user code.
static let maxNestingDepth = 64
init(
tokens: [Token],
scalarParser: Parser,
) {
cursor = TokenCursor(tokens: tokens)
self.scalarParser = scalarParser
events = []
eventSink = nil
}
mutating func parse() throws -> [Event] {
try parseEvents()
return events ?? []
}
init(
tokens: [Token],
scalarParser: Parser,
eventSink: @escaping (Event) throws -> Void,
) {
cursor = TokenCursor(tokens: tokens)
self.scalarParser = scalarParser
events = nil
self.eventSink = eventSink
}
init(
yaml: String,
scalarParser: Parser,
eventSink: @escaping (Event) throws -> Void,
) {
cursor = TokenCursor(yaml: yaml)
self.scalarParser = scalarParser
events = nil
self.eventSink = eventSink
}
mutating func parseStreaming() throws {
try parseEvents()
}
mutating func parseEvents() throws {
let streamStart = try expect("stream start") { kind in
if case .streamStart = kind {
return true
}
return false
}
try emit(.streamStart(mark: streamStart.mark))
var didParseDocument = false
while cursor.current?.kind.isStreamEnd != true {
try parseDocument()
didParseDocument = true
}
if !didParseDocument {
let mark = cursor.current?.mark ?? streamStart.endMark
try emit(.documentStart(mark: mark))
try appendEmptyDocument(at: mark)
try emit(.documentEnd(mark: mark))
}
let streamEnd = try expect("stream end") { kind in
if case .streamEnd = kind {
return true
}
return false
}
try emit(.streamEnd(mark: streamEnd.mark))
}
mutating func emit(_ event: Event) throws {
if let eventSink {
try eventSink(event)
} else {
events?.append(event)
}
}
}
}
extension PureYAML.Parsing.TokenEventParser {
mutating func parseDocument() throws {
let startMark = try parseDocumentStartMark()
try emit(.documentStart(mark: startMark))
if cursor.current?.kind.isDocumentEnd == true {
let end = try expect("document end") { $0.isDocumentEnd }
try appendEmptyDocument(at: end.mark)
try emit(.documentEnd(mark: end.endMark))
return
}
if cursor.current?.kind.isDocumentStart == true || cursor.current?.kind.isStreamEnd == true {
try appendEmptyDocument(at: cursor.current?.mark ?? startMark)
try emit(.documentEnd(mark: cursor.current?.mark ?? startMark))
return
}
var endMark = try parseDocumentRootNode().endMark
while cursor.current?.kind.isDedent == true {
let dedent = try expect("dedent") { $0.isDedent }
endMark = dedent.endMark
}
if cursor.current?.kind.isDocumentEnd == true {
let end = try expect("document end") { $0.isDocumentEnd }
endMark = end.endMark
}
if let current = cursor.current, !canEndDocument(at: current) {
throw PureYAML.Parsing.ParseError.unexpectedToken(
expected: "document boundary or stream end",
actual: current.kind.description,
line: current.mark.line,
column: current.mark.column,
)
}
try emit(.documentEnd(mark: endMark))
}
mutating func parseDocumentRootNode() throws -> PureYAML.Parsing.NodeResult {
guard cursor.current?.kind.isIndent == true else {
return try parseNode()
}
_ = try expect("indent") { $0.isIndent }
let result = try parseNode()
if cursor.current?.kind.isDedent == true {
_ = try expect("dedent") { $0.isDedent }
}
return result
}
mutating func parseDocumentStartMark() throws -> PureYAML.Parsing.Mark {
guard cursor.current?.kind.isDocumentStart == true else {
return cursor.current?.mark ?? cursor.previous?.endMark ?? .start
}
let start = try cursor.advance()
return start?.mark ?? .start
}
mutating func appendEmptyDocument(at mark: PureYAML.Parsing.Mark) throws {
try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark))
}
func canEndDocument(at token: PureYAML.Parsing.Token) -> Bool {
token.kind.isDocumentStart || token.kind.isStreamEnd
}
mutating func parseNode() throws -> PureYAML.Parsing.NodeResult {
let properties = try consumeProperties()
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)
}
/// An alias is a bare reference, so it cannot carry an anchor or tag of its own (`&b *a`).
mutating func parseAlias(
anchor: String,
token: PureYAML.Parsing.Token,
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
guard properties.anchor == nil, properties.tag == nil else {
let mark = properties.mark ?? token.mark
throw PureYAML.Parsing.ParseError.aliasWithProperties(line: mark.line, column: mark.column)
}
_ = try cursor.advance()
try emit(.alias(anchor: anchor, mark: token.mark))
return PureYAML.Parsing.NodeResult(kind: .alias, endMark: token.endMark)
}
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())
guard let token = cursor.current, !token.kind.isTerminator else {
let mark = properties.mark ?? cursor.current?.mark ?? cursor.previous?.endMark ?? .start
throw PureYAML.Parsing.ParseError.expectedNode(line: mark.line, column: mark.column)
}
switch token.kind {
case let .alias(anchor):
return try parseAlias(anchor: anchor, token: token, properties: properties)
case .blockEntry:
return try parseBlockSequence(properties: properties)
case .blockScalarHeader:
return try parseBlockScalar(properties: properties)
case .flowMappingStart:
return try parseFlowMapping(properties: properties)
case .flowSequenceStart:
return try parseFlowSequence(properties: properties)
case .mappingKey:
return try parseBlockMapping(
properties: properties,
includeIndentedContinuation: false,
)
case let .scalar(value, style):
if try isCommentInterruptedScalar() {
return try rejectCommentInterruptedScalar()
}
_ = try cursor.advance()
let decoded = try decodeScalar(value, style: style, line: token.mark.line)
let mark = properties.mark ?? token.mark
try emit(.scalar(
value: decoded,
anchor: properties.anchor,
tag: properties.tag,
style: style,
mark: mark,
))
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: token.endMark)
default:
throw PureYAML.Parsing.ParseError.expectedNode(line: token.mark.line, column: token.mark.column)
}
}
}