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.
153 lines
7.0 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|