use std::io::{self, BufRead};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Utf8Decode {
Clean,
TruncatedCodepoint { at: usize },
InvalidBytes { at: usize },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedLine {
pub text: String,
pub decode: Utf8Decode,
}
pub fn classify_utf8(line: &[u8]) -> Utf8Decode {
let mut rest = line;
let mut base = 0;
let mut truncated_at: Option<usize> = None;
loop {
match std::str::from_utf8(rest) {
Ok(_) => {
return match truncated_at {
Some(at) => Utf8Decode::TruncatedCodepoint { at },
None => Utf8Decode::Clean,
};
}
Err(e) => {
let valid = e.valid_up_to();
let at = base + valid;
match e.error_len() {
None => {
return Utf8Decode::TruncatedCodepoint {
at: truncated_at.unwrap_or(at),
};
}
Some(n) => {
let bad = &rest[valid..valid + n];
if !is_incomplete_multibyte(bad) {
return Utf8Decode::InvalidBytes { at };
}
truncated_at.get_or_insert(at);
base = at + n;
rest = &rest[valid + n..];
}
}
}
}
}
}
fn incomplete_tail_len(bytes: &[u8]) -> usize {
for back in 1..=bytes.len().min(3) {
let tail = &bytes[bytes.len() - back..];
if is_incomplete_multibyte(tail) {
return back;
}
}
0
}
fn is_incomplete_multibyte(seq: &[u8]) -> bool {
let Some((&lead, cont)) = seq.split_first() else {
return false;
};
let need = match lead {
0xC2..=0xDF => 2,
0xE0..=0xEF => 3,
0xF0..=0xF4 => 4,
_ => return false,
};
seq.len() < need && cont.iter().all(|&b| (0x80..=0xBF).contains(&b))
}
pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub fn decode_log_line(bytes: &[u8]) -> DecodedLine {
let mut buf = bytes;
if let Some((&b'\n', rest)) = buf.split_last() {
buf = rest;
}
DecodedLine {
text: String::from_utf8_lossy(buf).into_owned(),
decode: classify_utf8(buf),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct OverCap {
pub cap: usize,
pub line_bytes: usize,
}
impl OverCap {
pub fn new(cap: usize, line_bytes: usize) -> Self {
OverCap { cap, line_bytes }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CappedLine {
pub line: DecodedLine,
pub over_cap: Option<OverCap>,
}
pub fn read_log_lines<R: BufRead>(r: R) -> impl Iterator<Item = io::Result<DecodedLine>> {
read_lines(r, None).map(|res| res.map(|capped| capped.line))
}
pub fn read_log_lines_capped<R: BufRead>(
r: R,
max_bytes: usize,
) -> impl Iterator<Item = io::Result<CappedLine>> {
read_lines(r, Some(max_bytes))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct LineRead {
pub line_bytes: usize,
pub terminated: bool,
}
pub fn read_log_line_capped<R: BufRead>(
r: &mut R,
max_bytes: usize,
buf: &mut Vec<u8>,
) -> io::Result<Option<LineRead>> {
let mut line_bytes = 0;
let mut saw_bytes = false;
let mut terminated = false;
loop {
let available = match r.fill_buf() {
Ok(chunk) => chunk,
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
if available.is_empty() {
break;
}
saw_bytes = true;
let (content, consumed, done) = match available.iter().position(|&b| b == b'\n') {
Some(i) => (&available[..i], i + 1, true),
None => (available, available.len(), false),
};
let room = max_bytes.saturating_sub(buf.len()).min(content.len());
buf.extend_from_slice(&content[..room]);
line_bytes += content.len();
r.consume(consumed);
if done {
terminated = true;
break;
}
}
Ok(saw_bytes.then_some(LineRead {
line_bytes,
terminated,
}))
}
pub fn trim_capped_tail(buf: &mut Vec<u8>) {
buf.truncate(buf.len() - incomplete_tail_len(buf));
}
fn read_lines<R: BufRead>(
mut r: R,
cap: Option<usize>,
) -> impl Iterator<Item = io::Result<CappedLine>> {
let max = cap.unwrap_or(usize::MAX);
let mut buf = Vec::new();
std::iter::from_fn(move || {
buf.clear();
match read_log_line_capped(&mut r, max, &mut buf) {
Ok(None) => None,
Ok(Some(read)) => {
let over_cap = (read.line_bytes > max).then(|| {
trim_capped_tail(&mut buf);
OverCap {
cap: max,
line_bytes: read.line_bytes,
}
});
Some(Ok(CappedLine {
line: decode_log_line(&buf),
over_cap,
}))
}
Err(e) => Some(Err(e)),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn production_pattern_is_truncated() {
assert_eq!(
classify_utf8(b"\xe2\x80\x32"),
Utf8Decode::TruncatedCodepoint { at: 0 }
);
}
#[test]
fn truncation_then_clean_tail_latches_truncated() {
let line = b"CHANNEL_UN\xe2\x80then a long clean ASCII tail follows here";
assert_eq!(
classify_utf8(line),
Utf8Decode::TruncatedCodepoint { at: 10 }
);
}
#[test]
fn incomplete_at_end_of_line_is_truncated() {
assert_eq!(
classify_utf8(b"\xe2\x80"),
Utf8Decode::TruncatedCodepoint { at: 0 }
);
assert_eq!(
classify_utf8(b"\xe2"),
Utf8Decode::TruncatedCodepoint { at: 0 }
);
}
#[test]
fn malformed_bytes_are_invalid() {
assert_eq!(classify_utf8(b"\xff"), Utf8Decode::InvalidBytes { at: 0 });
assert_eq!(classify_utf8(b"\x80"), Utf8Decode::InvalidBytes { at: 0 });
assert_eq!(
classify_utf8(b"\xc0\x80"),
Utf8Decode::InvalidBytes { at: 0 }
);
}
#[test]
fn genuine_wins_over_earlier_truncation() {
match classify_utf8(b"\xe2\x80\x32\xff") {
Utf8Decode::InvalidBytes { .. } => {}
other => panic!("expected InvalidBytes, got {other:?}"),
}
}
#[test]
fn clean_line_is_clean() {
assert_eq!(classify_utf8("héllo wörld".as_bytes()), Utf8Decode::Clean);
assert_eq!(classify_utf8(b"plain ascii"), Utf8Decode::Clean);
}
#[test]
fn a_cr_before_the_newline_survives_the_decode() {
let decoded = decode_log_line(b"v=0\r\n");
assert_eq!(decoded.text, "v=0\r");
assert_eq!(decoded.decode, Utf8Decode::Clean);
}
#[test]
fn truncate_at_char_boundary_shorter_input_unchanged() {
assert_eq!(truncate_at_char_boundary("abc", 80), "abc");
assert_eq!(truncate_at_char_boundary("abc", 3), "abc");
assert_eq!(truncate_at_char_boundary("", 0), "");
}
#[test]
fn truncate_at_char_boundary_ascii_cut() {
assert_eq!(truncate_at_char_boundary("abcdef", 4), "abcd");
}
#[test]
fn truncate_at_char_boundary_backs_off_multibyte() {
assert_eq!(truncate_at_char_boundary("abcéf", 4), "abc");
let s = "ab\u{fffd}cd";
assert_eq!(truncate_at_char_boundary(s, 3), "ab");
assert_eq!(truncate_at_char_boundary(s, 4), "ab");
assert_eq!(truncate_at_char_boundary(s, 5), "ab\u{fffd}");
}
#[test]
fn read_log_lines_reports_per_line_verdict() {
let mut buf = Vec::new();
buf.extend_from_slice(b"first clean line\n");
buf.extend_from_slice(b"bad\xe2\x80stuff\n");
buf.extend_from_slice(b"third clean line\n");
let lines: Vec<DecodedLine> = read_log_lines(Cursor::new(buf))
.map(|d| d.expect("no io error"))
.collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].decode, Utf8Decode::Clean);
assert_eq!(lines[2].decode, Utf8Decode::Clean);
match lines[1].decode {
Utf8Decode::TruncatedCodepoint { .. } => {}
ref other => panic!("expected TruncatedCodepoint, got {other:?}"),
}
assert!(lines[1].text.starts_with("bad"));
assert!(lines[1].text.contains('\u{fffd}'));
assert!(lines[1].text.ends_with("stuff"));
}
fn capped(input: &'static [u8], max_bytes: usize) -> Vec<CappedLine> {
read_log_lines_capped(Cursor::new(input), max_bytes)
.map(|d| d.expect("no io error"))
.collect()
}
#[test]
fn a_line_exactly_at_the_cap_is_kept_whole() {
let lines = capped(b"abcde\nfg\n", 5);
assert_eq!(lines[0].line.text, "abcde");
assert_eq!(lines[0].over_cap, None);
assert_eq!(lines[1].line.text, "fg");
assert_eq!(lines[1].over_cap, None);
}
#[test]
fn a_line_past_the_cap_reports_the_full_physical_length() {
let lines = capped(b"abcdef\n", 5);
assert_eq!(lines[0].line.text, "abcde");
assert_eq!(
lines[0].over_cap,
Some(OverCap {
cap: 5,
line_bytes: 6
})
);
}
#[test]
fn the_readers_own_cut_never_reads_as_a_logger_truncation() {
let lines = capped("abcéd\n".as_bytes(), 4);
assert_eq!(lines[0].line.text, "abc");
assert_eq!(lines[0].line.decode, Utf8Decode::Clean);
assert_eq!(lines[0].over_cap.expect("over cap").line_bytes, 6);
}
#[test]
fn a_logger_truncation_under_the_cap_is_still_reported() {
let lines = capped(b"bad\xe2\x80\n", 100);
assert_eq!(lines[0].over_cap, None);
assert!(matches!(
lines[0].line.decode,
Utf8Decode::TruncatedCodepoint { at: 3 }
));
}
#[test]
fn consecutive_over_cap_lines_do_not_leak_bytes_into_each_other() {
let lines = capped(b"aaaaaaaa\nbbbbbbbb\ncc\n", 3);
assert_eq!(lines[0].line.text, "aaa");
assert_eq!(lines[1].line.text, "bbb");
assert_eq!(lines[2].line.text, "cc");
assert_eq!(lines[2].over_cap, None);
}
#[test]
fn a_final_line_without_a_newline_is_yielded() {
let lines = capped(b"first\nlast no newline", 3);
assert_eq!(lines.len(), 2);
assert_eq!(lines[1].line.text, "las");
assert_eq!(lines[1].over_cap.expect("over cap").line_bytes, 15);
}
#[test]
fn a_zero_cap_keeps_the_framing_and_drops_every_byte() {
let lines = capped(b"abc\n\ndef\n", 0);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].line.text, "");
assert_eq!(lines[0].over_cap.expect("over cap").line_bytes, 3);
assert_eq!(lines[1].over_cap, None);
}
#[test]
fn a_line_spanning_several_reader_refills_counts_every_byte() {
let input: Vec<u8> = {
let mut v = vec![b'x'; 500];
v.push(b'\n');
v
};
let reader = io::BufReader::with_capacity(16, Cursor::new(input));
let lines: Vec<CappedLine> = read_log_lines_capped(reader, 100)
.map(|d| d.expect("no io error"))
.collect();
assert_eq!(lines[0].line.text.len(), 100);
assert_eq!(lines[0].over_cap.expect("over cap").line_bytes, 500);
}
}