PureYAML/Sources/Parsing/LibYAML.Reader.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

153 lines
7.0 KiB
Swift

extension PureYAML.Parsing.LibYAML {
/// Port of libyaml's input buffer and position tracking (the `buffer`/`mark`
/// fields with the `PEEK`, `SKIP`, and `SKIP_LINE` macros). It iterates
/// `Unicode.Scalar`s rather than grapheme clusters so a CR and an LF are
/// distinct, matching libyaml's byte-oriented scanning, and tracks the mark in
/// libyaml's 0-based line and column convention; conversion to the package's
/// 1-based `Mark` happens at token emission during integration.
struct Reader {
private var scalars: [Unicode.Scalar]
private(set) var index = 0
private(set) var line = 0
private(set) var column = 0
/// Whether the buffered `scalars` are the complete input. True for every
/// batch caller (the default), so `ranDry` can never be set and this type's
/// behavior is unchanged for `scanAll()`. A resumable caller passes `false`
/// and later calls `markFinal()` once the last chunk has been appended.
private(set) var isFinal: Bool
/// Set by `peek(_:)` when it was asked for a position past the buffered
/// scalars while `!isFinal`: the query is inconclusive, not a real end of
/// input, and the caller must suspend and retry after `append(_:)`. Batch
/// callers (`isFinal == true`) never set this, since the guard below is
/// unreachable when `isFinal` is true.
private(set) var ranDry = false
/// The index of the first non-`c-printable` scalar found in `scalars`
/// (libyaml's reader-level check, `yaml_parser_update_buffer`'s printable
/// validation), or nil if none has been found yet. Set once, by whichever
/// of `init`/`append(_:)` first buffers the offending scalar; never
/// cleared. `fetchNextToken()` checks this once per token and throws,
/// mirroring libyaml rejecting raw control characters (anything outside
/// `c-printable`, i.e. not a plain tab/CR/LF and not in the printable
/// ranges `CharacterClass.isPrintable` implements) wherever they occur in
/// the stream, not only inside specific constructs like anchor names.
private(set) var invalidControlCharacterIndex: Int?
init(_ string: String = "", isFinal: Bool = true) {
scalars = Array(string.unicodeScalars)
self.isFinal = isFinal
recordFirstInvalidControlCharacter(in: 0 ..< scalars.count)
}
/// `PEEK_TOKEN` / `CHECK_AT`: the scalar `offset` ahead, or nil past the
/// end (libyaml's `\0`). Past the buffered end while more chunks may still
/// arrive, this flags `ranDry` instead of asserting true end of input.
mutating func peek(_ offset: Int = 0) -> Unicode.Scalar? {
let position = index + offset
if position < scalars.count {
return scalars[position]
}
if !isFinal {
ranDry = true
}
return nil
}
/// Append a chunk of newly arrived input. Only meaningful before
/// `markFinal()`. Clears `ranDry`: new data may satisfy whatever `peek(_:)`
/// call set it, and nothing else ever clears it. The existing
/// checkpoint-restore path gets this for free (the restored `Reader` is a
/// copy from before the dry read), but a resumed scan that keeps its
/// reader position instead of restoring a snapshot (`PlainScalarSuspended`)
/// has no other point where a stale `true` would ever become false again.
mutating func append(_ chunk: String) {
let previousCount = scalars.count
scalars.append(contentsOf: chunk.unicodeScalars)
ranDry = false
recordFirstInvalidControlCharacter(in: previousCount ..< scalars.count)
}
/// Declare that no further chunks are coming: a `peek` past the buffered
/// end is now a real end of input, matching the batch reader's behavior.
mutating func markFinal() {
isFinal = true
}
var isAtEnd: Bool {
index >= scalars.count
}
/// The current position. Column is 0-based, as in libyaml.
var mark: PureYAML.Parsing.Mark {
PureYAML.Parsing.Mark(line: line, column: column, index: index)
}
/// `SKIP`: consume one non-break scalar, advancing the column.
mutating func skip() {
guard index < scalars.count else {
return
}
index += 1
column += 1
}
/// `SKIP_LINE`: consume a line break and start a new line. A CR+LF pair is
/// one break, matching libyaml.
mutating func skipLine() {
if peek() == "\r", peek(1) == "\n" {
index += 2
} else if CharacterClass.isBreak(peek()) {
index += 1
} else {
return
}
line += 1
column = 0
}
/// The 0-based line/column of `scalars[index]`, computed by counting line
/// breaks from the start of the buffer. Not the hot `mark` path (that
/// tracks `line`/`column` incrementally as scanning consumes characters);
/// used only once, lazily, when `fetchNextToken()` actually needs to report
/// `invalidControlCharacterIndex` as a `Mark`, since that index can be
/// anywhere in the buffer, not necessarily at the reader's current
/// scanning position.
func mark(atIndex target: Int) -> PureYAML.Parsing.Mark {
var line = 0
var column = 0
var cursor = 0
while cursor < target, cursor < scalars.count {
if scalars[cursor] == "\r", cursor + 1 < scalars.count, scalars[cursor + 1] == "\n" {
line += 1
column = 0
cursor += 2
} else if CharacterClass.isBreak(scalars[cursor]) {
line += 1
column = 0
cursor += 1
} else {
column += 1
cursor += 1
}
}
return PureYAML.Parsing.Mark(line: line, column: column, index: target)
}
/// Scans `range` (a slice of `scalars` just buffered by `init`/`append(_:)`)
/// for the first scalar outside `CharacterClass.isPrintable`, recording its
/// index if one is found and none was already recorded. Only the first
/// occurrence overall matters (matching libyaml, which reports the first
/// invalid byte and stops), so once `invalidControlCharacterIndex` is set
/// this is a no-op.
private mutating func recordFirstInvalidControlCharacter(in range: Range<Int>) {
guard invalidControlCharacterIndex == nil else {
return
}
for index in range where !CharacterClass.isPrintable(scalars[index]) {
invalidControlCharacterIndex = index
return
}
}
}
}