PureYAML/Tests/InvalidControlCharacterTests.swift
Mihaela Mihaljevic f9fe2325f7 fix(parsing): reject raw control characters in parse(), matching libyaml
PureYAML.parse() silently accepted raw C0 control characters anywhere in
the input; Yams's underlying libyaml rejects them unconditionally at the
reader level. Found by comparing parse() directly against Yams on a
real-world OpenAPI corpus. The printable-character predicate this needed
already existed (CharacterClass.isPrintable, matching libyaml's
c-printable set) but was only ever wired into anchor-name validation, and
the separate PreflightScanner control-character check is opt-in via
validate(), not part of what parse() itself runs.

Reader now records the index of the first non-printable scalar as it
buffers input (covering both batch and streaming), and fetchNextToken()
throws once scanning has actually reached that position, not merely once
one exists, so an earlier real syntax error is not hidden behind a later
control character.

Verified with 5 new tests and a full re-run of the official yaml-test-suite
conformance (333/333 parse-level, 208/208 value-level, unchanged) and the
whole-suite streaming differential (still clean), confirming no regression.
2026-07-10 11:03:27 +02:00

56 lines
2.4 KiB
Swift

@testable import PureYAML
import Testing
/// The reader-level control-character check (`Reader.recordFirstInvalidControlCharacter`,
/// `fetchNextToken()`'s check in `LibYAML.Scanner.Fetch.swift`) added to close a real gap
/// found comparing PureYAML against Yams on a real-world OpenAPI corpus: Yams's underlying
/// libyaml rejects raw control characters (`c-printable` violations) anywhere in the input,
/// while `PureYAML.parse()` previously accepted them silently (the only existing check,
/// `PreflightScanner.invalidControlCharacterColumn`, is opt-in via `validate()`, not part of
/// the core scanner `parse()` uses).
@Suite("Invalid control character rejection")
struct InvalidControlCharacterTests {
@Test("A raw control character in scalar content is rejected with the correct position")
func test_rawControlCharacterRejected() {
expectParseError(
"key: value\u{01}with control\n",
.invalidControlCharacter(line: 1, column: 11),
)
}
@Test("A tab in scalar content, not indentation, is still legal")
func test_tabInContentStillAccepted() throws {
let value = try PureYAML.parse("key: value\twith tab\n")
let mapping = requireMapping(value)
#expect(mapping?["key"] == .string("value\twith tab"))
}
@Test("CR and LF are still legal")
func test_crAndLFStillAccepted() throws {
let value = try PureYAML.parse("key: |\n line one\n line two\n")
let mapping = requireMapping(value)
#expect(mapping?["key"] == .string("line one\nline two\n"))
}
@Test("A control character deep inside a large document is still caught")
func test_controlCharacterFarIntoDocumentIsCaught() {
let padding = String(repeating: "line: value\n", count: 5000)
expectParseError(
padding + "bad: value\u{02}here\n",
.invalidControlCharacter(line: 5001, column: 11),
)
}
@Test("An earlier real syntax error is not hidden behind a later control character")
func test_earlierSyntaxErrorNotHiddenByLaterControlCharacter() {
do {
_ = try PureYAML.parse("key: \"unterminated\nbad: value\u{02}here\n")
recordIssue("expected parse to throw")
} catch let error as PureYAML.Parsing.ParseError {
#expect(error.diagnosticCode != "invalidControlCharacter")
} catch {
recordIssue("expected a ParseError, got \(error)")
}
}
}