← ./articles-ja

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も追加します。

参考