PureYAML/Sources/Parsing/Scanner.Directives.swift
Mihaela M fcba4e1f57 fix(parsing): reject content after a document end marker
A document-end marker takes no node on its own line: only a comment or
blank may follow. The scanner skipped the rest of the marker's line, so
content such as the 'invalid' in '... invalid' was silently dropped and
the document accepted.

scanDocumentMarker now distinguishes the two markers after skipping spaces:
a document start may carry the root node on its line and leaves it for the
main loop, while a document end rejects any real content (a comment or
blank still passes). A '...' not followed by an indicator boundary is not
a marker at all and remains an ordinary plain scalar, so '...junk' still
parses as content.

Measured against the official YAML test-suite, parse-level conformance
rises from 255/333 (76.6%) to 256/333 (76.9%) with no regression.
2026-06-27 17:56:37 +02:00

123 lines
5.1 KiB
Swift

extension PureYAML.Parsing.Scanner {
func scanDirectiveOrDocumentMarker(_ state: inout State) throws -> Bool {
guard state.indentation.last == 0 else {
return false
}
if state.reader.peek() == "%" {
try scanDirective(&state)
return true
}
if state.reader.peek() == "#" {
return false
}
if startsWith("---", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
try scanDocumentMarker(.documentStart, state: &state)
resetForDocumentStart(&state)
return true
}
if startsWith("...", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
guard !state.isDocumentClosed else {
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line)
}
// A `...` ends a document, so a directive still awaiting its `---` never gets one.
try enforceDirectiveHasDocument(state)
try scanDocumentMarker(.documentEnd, state: &state)
resetForDocumentEnd(&state)
return true
}
guard !state.isDocumentClosed else {
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line)
}
return false
}
func scanDocumentMarker(
_ kind: PureYAML.Parsing.TokenKind,
state: inout State,
) throws {
let start = state.reader.mark
state.reader.advance()
state.reader.advance()
state.reader.advance()
state.append(kind, mark: start, endMark: state.reader.mark)
while state.reader.peek() == " " {
state.reader.advance()
}
if let next = state.reader.peek(), !isLineBreak(next), next != "#" {
// The marker's own line carries real content. A document start may carry the root node
// here (e.g. `--- >`, `--- foo`, `--- {a: 1}`), so leave it for the main loop to scan.
if case .documentStart = kind {
state.isAtLineStart = false
return
}
// A document end (`...`) takes no same-line node; only a comment or blank may follow it.
throw PureYAML.Parsing.ParseError.contentAfterDocumentEnd(line: state.reader.mark.line)
}
skipLine(&state)
}
func resetForDocumentStart(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = true
state.isDocumentClosed = false
state.hasYAMLDirective = false
state.pendingDirectiveLine = nil
}
/// A directive must be followed by an explicit `---` document start. Called at a document end
/// and at stream end, where a still-pending directive means none ever came.
func enforceDirectiveHasDocument(_ state: State) throws {
if let line = state.pendingDirectiveLine {
throw PureYAML.Parsing.ParseError.directiveWithoutDocument(line: line)
}
}
func resetForDocumentEnd(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = false
state.isDocumentClosed = true
state.hasYAMLDirective = false
}
func scanDirective(_ state: inout State) throws {
let start = state.reader.mark
let line = state.reader.consume { character in
!isLineBreak(character)
}
let parts = line.split(separator: " ").map(String.init)
guard let name = parts.first else {
return
}
guard !state.hasDocumentContent, !state.hasDocumentStartMarker, !state.isDocumentClosed else {
throw PureYAML.Parsing.ParseError.unsupportedDirective(name: name, line: start.line)
}
// Every directive must be followed by an explicit `---`; record that one is now awaiting it.
state.pendingDirectiveLine = start.line
switch name {
case "%YAML":
guard parts.indices.contains(1), parts[1] == "1.1" || parts[1] == "1.2" else {
throw PureYAML.Parsing.ParseError.incompatibleYAMLDirective(line: start.line)
}
// `%YAML` takes exactly one parameter, the version; only a comment may follow it.
guard parts.count == 2 || parts[2].hasPrefix("#") else {
throw PureYAML.Parsing.ParseError.malformedDirective(name: name, line: start.line)
}
// At most one `%YAML` directive is allowed per document.
guard !state.hasYAMLDirective else {
throw PureYAML.Parsing.ParseError.duplicateDirective(name: name, line: start.line)
}
state.hasYAMLDirective = true
case "%TAG":
guard parts.count >= 3 else {
return
}
state.tagHandles[parts[1]] = parts[2]
default:
// A YAML processor ignores a directive it does not recognize (the spec calls for a
// warning and otherwise no effect), rather than rejecting the document. The line has
// already been consumed, so there is nothing more to do.
return
}
}
}