Rustのfrom_utf8_lossyでstream readerのbyte offsetを計算しない
String::from_utf8_lossy は、不完全なbyte列を表示したい時には便利です。しかし、その結果の文字列から元bufferへのbyte offsetを計算する用途では危険です。
ルールは単純です。seek、tail、resume、raw byte slice、file cursor更新に使うoffsetは、必ずraw bytes上で計算します。decodeはbyte boundaryが決まった後です。
バグの形
一見自然な実装です。
fn read_complete_lines(raw: &[u8], file_offset: &mut u64) {
let text = String::from_utf8_lossy(raw);
if let Some(last_newline) = text.rfind('\n') {
let consumed = last_newline + 1;
*file_offset += consumed as u64;
let complete = &text[..consumed];
process_lines(complete);
}
}
問題は、last_newline がlossy string内のindexであり、raw 内のbyte offsetではないことです。
不正または途中で切れたUTF-8があると、from_utf8_lossy はUnicode replacement characterを挿入します。このreplacement characterのbyte lengthは、元の不正byte列と一致するとは限りません。cursorが進みすぎたり、足りなかったりします。
正しいpattern: byte boundaryを先に探す
delimiterは元のbyte列で探します。
fn read_complete_lines(raw: &[u8], file_offset: &mut u64) {
let Some(last_newline_byte_index) = raw.iter().rposition(|&byte| byte == b'\n') else {
return;
};
let complete_byte_len = last_newline_byte_index + 1;
let complete = String::from_utf8_lossy(&raw[..complete_byte_len]);
process_lines(&complete);
*file_offset += complete_byte_len as u64;
}
これで、進めるoffsetは実際に消費したbyte数と一致します。
tail readerで重要な理由
tail readerはだいたいこう動きます。
file_offsetを覚える
file_offsetからbyteを読む
complete recordだけ処理する
file_offsetを進める
途中のbyteを次回へ持ち越す
file_offset がズレると、次の問題が起きます。
- byteをskipする
- byteを再読する
- chunk boundaryのrecordを壊す
- lineを重複parseする
- 実file positionから徐々にズレる
from_utf8_lossy はthrowしないので、後段のparser bugに見えやすいです。
JSONLやdelimiter formatでも同じ
JSON Linesなどでも、まずbyteでsplitします。
fn split_complete_jsonl(raw: &[u8]) -> (&[u8], &[u8]) {
match raw.iter().rposition(|&byte| byte == b'\n') {
Some(index) => raw.split_at(index + 1),
None => (&[], raw),
}
}
complete partだけdecodeします。
let (complete, trailing) = split_complete_jsonl(&buffer);
let text = String::from_utf8_lossy(complete);
for line in text.lines() {
process_json_line(line);
}
buffer = trailing.to_vec();
trailing bytesには、不完全なUTF-8、不完全なJSON、または両方が含まれます。次のreadまでbyteのまま保持します。
不正UTF-8を許さないならstrict decode
from_utf8_lossy はtolerant displayやbest-effort parsing向けです。不正UTF-8を壊れたinputとして扱うならstrictにします。
let text = std::str::from_utf8(complete)
.map_err(|error| format!("Invalid UTF-8 in log chunk: {error}"))?;
logや外部toolではlossyが許容できることがあります。protocol、index、cache、signed dataでは通常避けます。
検証テスト
途中で切れた文字を含むchunkをテストします。
#[test]
fn offset_is_computed_on_raw_bytes() {
let raw = b"ok\nbad:\xE3\x81\nnext\n";
let newline = raw.iter().rposition(|&byte| byte == b'\n').unwrap();
let complete_len = newline + 1;
assert_eq!(&raw[..3], b"ok\n");
assert_eq!(complete_len, raw.len());
}
最後にnewlineがないchunkではcursorを進めないtestも追加します。