From 27c423dbb93458d1aa7ead34a62d5d06bc8e3bc8 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sat, 27 Jun 2026 17:18:47 +0200 Subject: [PATCH] fix(parsing): require an explicit document after a directive The YAML grammar requires every directive to head an explicit document, so a directive must be followed by a '---' marker. The scanner accepted a directive with no document at all: '%YAML 1.2' alone, or '%YAML 1.2' then a '...' document-end marker, parsed without error. The scanner now records the line of a directive awaiting its document, clears it at a document start, and rejects a still-pending directive at a document-end marker and at stream end. Both scan drivers finalize through the same stream-end path, so the check lives in one helper called from each. The rule applies to any directive, including an unrecognized one (which is otherwise still ignored); the directive test that relied on an unrecognized directive parsing without a '---' is corrected to include one, since that leniency is not permitted by the grammar. Measured against the official YAML test-suite, parse-level conformance rises from 253/333 (76.0%) to 255/333 (76.6%) with no regression. --- CHANGELOG.md | 7 +++++++ Sources/Parsing/ParseError.swift | 11 +++++++++-- Sources/Parsing/Scanner.Directives.swift | 13 +++++++++++++ Sources/Parsing/Scanner.swift | 5 +++++ Sources/Parsing/TokenCursor.swift | 1 + Tests/DirectiveTests.swift | 5 +++-- Tests/YAMLDirectiveTests.swift | 17 +++++++++++++++++ 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d26d85..6b4ef04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A directive must now be followed by an explicit `---` document start. A stream that ends, or + reaches a `...` document-end marker, while a directive is still awaiting its document + (`%YAML 1.2` alone, or `%YAML 1.2` then `...`) is now rejected, as the YAML grammar requires a + directive to head an explicit document. This applies to any directive, including an unrecognized + one, which is still otherwise ignored. A directive followed by `---` (with content, inline, or + empty) is unaffected. Lifts official test-suite parse-level conformance from 253/333 (76.0%) to + 255/333 (76.6%), with no regression. - The `%YAML` directive is now validated. It takes exactly one parameter, the version, optionally followed by a comment, and at most one may appear per document; an extra parameter (`%YAML 1.2 foo`) or a second `%YAML` directive is now rejected. Previously any tokens after the diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index ccd6c0e..01d8783 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -22,6 +22,7 @@ public extension PureYAML.Parsing { case invalidEscape(sequence: String, line: Int) case malformedDirective(name: String, line: Int) case duplicateDirective(name: String, line: Int) + case directiveWithoutDocument(line: Int) public var description: String { switch self { @@ -67,6 +68,8 @@ public extension PureYAML.Parsing { "malformed directive '\(name)' at line \(line)" case let .duplicateDirective(name, line): "duplicate directive '\(name)' at line \(line)" + case let .directiveWithoutDocument(line): + "a directive must be followed by a document at line \(line)" } } @@ -114,6 +117,8 @@ public extension PureYAML.Parsing { "malformedDirective" case .duplicateDirective: "duplicateDirective" + case .directiveWithoutDocument: + "directiveWithoutDocument" } } @@ -130,7 +135,8 @@ public extension PureYAML.Parsing { let .unsupportedMultiDocumentStream(line), let .invalidEscape(_, line), let .malformedDirective(_, line), - let .duplicateDirective(_, line): + let .duplicateDirective(_, line), + let .directiveWithoutDocument(line): line case let .expectedNode(line, _), let .expectedScalarKey(line, _), @@ -158,7 +164,8 @@ public extension PureYAML.Parsing { .unsupportedMultiDocumentStream, .invalidEscape, .malformedDirective, - .duplicateDirective: + .duplicateDirective, + .directiveWithoutDocument: nil case let .expectedNode(_, column), let .expectedScalarKey(_, column), diff --git a/Sources/Parsing/Scanner.Directives.swift b/Sources/Parsing/Scanner.Directives.swift index 0c42d6f..7c5f19f 100644 --- a/Sources/Parsing/Scanner.Directives.swift +++ b/Sources/Parsing/Scanner.Directives.swift @@ -19,6 +19,8 @@ extension PureYAML.Parsing.Scanner { 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) scanDocumentMarker(.documentEnd, state: &state) resetForDocumentEnd(&state) return true @@ -59,6 +61,15 @@ extension PureYAML.Parsing.Scanner { 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) { @@ -80,6 +91,8 @@ extension PureYAML.Parsing.Scanner { 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 { diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index f9a8e69..0a73f35 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -33,6 +33,7 @@ extension PureYAML.Parsing { } closeIndentation(&state) + try enforceDirectiveHasDocument(state) state.append(.streamEnd) return state.tokens } @@ -49,6 +50,10 @@ extension PureYAML.Parsing.Scanner { var hasDocumentStartMarker = false var isDocumentClosed = false var hasYAMLDirective = false + // The line of a directive awaiting its document. A directive must be followed by an explicit + // `---`; this is set when one is scanned, cleared at a document start, and checked at a + // document end and at stream end so a directive with no document is rejected. + var pendingDirectiveLine: Int? var flowDepth = 0 var flowContexts: [PureYAML.Parsing.ScannerFlowContext] = [] var validImplicitIndentation: Set = [] diff --git a/Sources/Parsing/TokenCursor.swift b/Sources/Parsing/TokenCursor.swift index c4579dd..6f079ef 100644 --- a/Sources/Parsing/TokenCursor.swift +++ b/Sources/Parsing/TokenCursor.swift @@ -80,6 +80,7 @@ extension PureYAML.Parsing { while scannerBox.state.tokens.isEmpty { guard !scannerBox.state.reader.isAtEnd else { scanner.closeIndentation(&scannerBox.state) + try scanner.enforceDirectiveHasDocument(scannerBox.state) scannerBox.state.append(.streamEnd) drainScannedTokens(from: &scannerBox.state) self.scannerBox = nil diff --git a/Tests/DirectiveTests.swift b/Tests/DirectiveTests.swift index cb8cc6d..26ca076 100644 --- a/Tests/DirectiveTests.swift +++ b/Tests/DirectiveTests.swift @@ -6,10 +6,11 @@ struct DirectiveTests { @Test("An unrecognized directive is ignored, not rejected") func test_unknownDirectiveIsIgnored() throws { // The YAML spec ignores a directive it does not recognize (with a warning), so the - // document still parses. + // document still parses. Directives must still be followed by an explicit `---`, so these + // cases include one: the point here is that `%FOO` itself does not cause a rejection. #expect(try PureYAML.parse("%FOO bar baz\n---\n\"foo\"\n") == .string("foo")) #expect( - try PureYAML.parse("%FOO bar\nvalue: one\n") + try PureYAML.parse("%FOO bar\n---\nvalue: one\n") == .mapping(.init([.init(key: "value", value: .string("one"))])), ) } diff --git a/Tests/YAMLDirectiveTests.swift b/Tests/YAMLDirectiveTests.swift index 5ec13b1..690fd71 100644 --- a/Tests/YAMLDirectiveTests.swift +++ b/Tests/YAMLDirectiveTests.swift @@ -20,4 +20,21 @@ struct YAMLDirectiveTests { #expect(try PureYAML.parse("%YAML 1.2\n---\nx\n") == expected) #expect(try PureYAML.parse("%YAML 1.2 # version\n---\nx\n") == expected) } + + @Test("A directive with no following document is rejected", arguments: [ + "%YAML 1.2\n", // directive then end of stream + "%YAML 1.2\n...\n", // directive then a document-end marker + "%TAG !e! tag:example,0:\n", // any directive, not just %YAML + ]) + func test_directiveWithoutDocumentRejected(_ yaml: String) throws { + #expect(throws: PureYAML.Parsing.ParseError.self) { + try PureYAML.parse(yaml) + } + } + + @Test("A directive followed by an explicit document start is accepted") + func test_directiveWithDocumentAccepted() throws { + #expect(try PureYAML.parse("%YAML 1.2\n---\n") == .null) + #expect(try PureYAML.parse("%TAG !e! tag:example,0:\n---\nx\n") == .string("x")) + } }