From fcba4e1f571de8391df7fb47e53e3869a81c059b Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sat, 27 Jun 2026 17:56:37 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 6 ++++++ Sources/Parsing/ParseError.swift | 11 ++++++++-- Sources/Parsing/Scanner.Directives.swift | 24 ++++++++++----------- Tests/DocumentEndContentTests.swift | 27 ++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 14 deletions(-) create mode 100644 Tests/DocumentEndContentTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4ef04..237ae80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A `...` document-end marker no longer accepts content on its own line. Only a comment or blank + may follow the marker, so `... invalid` is now rejected; the scanner previously skipped the rest + of the line. A bare `...`, a trailing comment (`... # done`), and a `...` not followed by a + boundary (`...junk`, which is an ordinary plain scalar, not a marker) are unaffected. Lifts + official test-suite parse-level conformance from 255/333 (76.6%) to 256/333 (76.9%), with no + regression. - 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 diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index 01d8783..3d3bae1 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -23,6 +23,7 @@ public extension PureYAML.Parsing { case malformedDirective(name: String, line: Int) case duplicateDirective(name: String, line: Int) case directiveWithoutDocument(line: Int) + case contentAfterDocumentEnd(line: Int) public var description: String { switch self { @@ -70,6 +71,8 @@ public extension PureYAML.Parsing { "duplicate directive '\(name)' at line \(line)" case let .directiveWithoutDocument(line): "a directive must be followed by a document at line \(line)" + case let .contentAfterDocumentEnd(line): + "no content may follow a document end marker at line \(line)" } } @@ -119,6 +122,8 @@ public extension PureYAML.Parsing { "duplicateDirective" case .directiveWithoutDocument: "directiveWithoutDocument" + case .contentAfterDocumentEnd: + "contentAfterDocumentEnd" } } @@ -136,7 +141,8 @@ public extension PureYAML.Parsing { let .invalidEscape(_, line), let .malformedDirective(_, line), let .duplicateDirective(_, line), - let .directiveWithoutDocument(line): + let .directiveWithoutDocument(line), + let .contentAfterDocumentEnd(line): line case let .expectedNode(line, _), let .expectedScalarKey(line, _), @@ -165,7 +171,8 @@ public extension PureYAML.Parsing { .invalidEscape, .malformedDirective, .duplicateDirective, - .directiveWithoutDocument: + .directiveWithoutDocument, + .contentAfterDocumentEnd: nil case let .expectedNode(_, column), let .expectedScalarKey(_, column), diff --git a/Sources/Parsing/Scanner.Directives.swift b/Sources/Parsing/Scanner.Directives.swift index 7c5f19f..a25c684 100644 --- a/Sources/Parsing/Scanner.Directives.swift +++ b/Sources/Parsing/Scanner.Directives.swift @@ -11,7 +11,7 @@ extension PureYAML.Parsing.Scanner { return false } if startsWith("---", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) { - scanDocumentMarker(.documentStart, state: &state) + try scanDocumentMarker(.documentStart, state: &state) resetForDocumentStart(&state) return true } @@ -21,7 +21,7 @@ extension PureYAML.Parsing.Scanner { } // A `...` ends a document, so a directive still awaiting its `---` never gets one. try enforceDirectiveHasDocument(state) - scanDocumentMarker(.documentEnd, state: &state) + try scanDocumentMarker(.documentEnd, state: &state) resetForDocumentEnd(&state) return true } @@ -34,24 +34,24 @@ extension PureYAML.Parsing.Scanner { 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) - // The document's root node may begin on the marker's own line (e.g. `--- >`, `--- foo`, - // `--- {a: 1}`, `--- !!str x`). After a document start, skip only the spaces and leave any - // real content for the main loop to scan; an empty rest-of-line or a comment is consumed. - // A document end (`...`) takes no same-line node, so its line is always skipped. - if case .documentStart = kind { - while state.reader.peek() == " " { - state.reader.advance() - } - if let next = state.reader.peek(), !isLineBreak(next), next != "#" { + 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) } diff --git a/Tests/DocumentEndContentTests.swift b/Tests/DocumentEndContentTests.swift new file mode 100644 index 0000000..954cd23 --- /dev/null +++ b/Tests/DocumentEndContentTests.swift @@ -0,0 +1,27 @@ +@testable import PureYAML +import Testing + +@Suite("Content after a document end marker") +struct DocumentEndContentTests { + @Test("Real content on a `...` marker's line is rejected") + func test_contentAfterDocumentEndRejected() throws { + #expect(throws: PureYAML.Parsing.ParseError.self) { + try PureYAML.parse("---\nkey: value\n... invalid\n") + } + } + + @Test("A bare `...`, or one followed only by a comment, is accepted", arguments: [ + "---\nx\n...\n", // nothing after the marker + "---\nx\n... # comment\n", // only a comment follows + "---\nx\n... \n", // only trailing spaces follow + ]) + func test_bareDocumentEndAccepted(_ yaml: String) throws { + #expect(try PureYAML.parse(yaml) == .string("x")) + } + + @Test("`...` not followed by a boundary is a plain scalar, not a marker") + func test_dotsWithoutBoundaryIsScalar() throws { + // `...junk` is not a document-end marker, so it continues the plain scalar `x`. + #expect(try PureYAML.parse("---\nx\n...junk\n") == .string("x ...junk")) + } +}