1use std::io::{self, BufRead};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Utf8Decode {
17 Clean,
19 TruncatedCodepoint { at: usize },
23 InvalidBytes { at: usize },
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DecodedLine {
31 pub text: String,
34 pub decode: Utf8Decode,
35}
36
37pub fn classify_utf8(line: &[u8]) -> Utf8Decode {
40 let mut rest = line;
41 let mut base = 0;
42 let mut truncated_at: Option<usize> = None;
45 loop {
46 match std::str::from_utf8(rest) {
47 Ok(_) => {
48 return match truncated_at {
49 Some(at) => Utf8Decode::TruncatedCodepoint { at },
50 None => Utf8Decode::Clean,
51 };
52 }
53 Err(e) => {
54 let valid = e.valid_up_to();
55 let at = base + valid;
56 match e.error_len() {
57 None => {
58 return Utf8Decode::TruncatedCodepoint {
59 at: truncated_at.unwrap_or(at),
60 };
61 }
62 Some(n) => {
63 let bad = &rest[valid..valid + n];
64 if !is_incomplete_multibyte(bad) {
65 return Utf8Decode::InvalidBytes { at };
66 }
67 truncated_at.get_or_insert(at);
68 base = at + n;
69 rest = &rest[valid + n..];
70 }
71 }
72 }
73 }
74 }
75}
76
77fn incomplete_tail_len(bytes: &[u8]) -> usize {
83 for back in 1..=bytes.len().min(3) {
84 let tail = &bytes[bytes.len() - back..];
85 if is_incomplete_multibyte(tail) {
86 return back;
87 }
88 }
89 0
90}
91
92fn is_incomplete_multibyte(seq: &[u8]) -> bool {
95 let Some((&lead, cont)) = seq.split_first() else {
96 return false;
97 };
98 let need = match lead {
99 0xC2..=0xDF => 2,
100 0xE0..=0xEF => 3,
101 0xF0..=0xF4 => 4,
102 _ => return false,
103 };
104 seq.len() < need && cont.iter().all(|&b| (0x80..=0xBF).contains(&b))
105}
106
107pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
114 if s.len() <= max_bytes {
115 return s;
116 }
117 let mut end = max_bytes;
118 while !s.is_char_boundary(end) {
119 end -= 1;
120 }
121 &s[..end]
122}
123
124pub fn decode_log_line(bytes: &[u8]) -> DecodedLine {
134 let mut buf = bytes;
135 if let Some((&b'\n', rest)) = buf.split_last() {
136 buf = rest;
137 }
138 DecodedLine {
139 text: String::from_utf8_lossy(buf).into_owned(),
140 decode: classify_utf8(buf),
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct OverCap {
148 pub cap: usize,
150 pub line_bytes: usize,
153}
154
155impl OverCap {
156 pub fn new(cap: usize, line_bytes: usize) -> Self {
158 OverCap { cap, line_bytes }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct CappedLine {
165 pub line: DecodedLine,
166 pub over_cap: Option<OverCap>,
170}
171
172pub fn read_log_lines<R: BufRead>(r: R) -> impl Iterator<Item = io::Result<DecodedLine>> {
182 read_lines(r, None).map(|res| res.map(|capped| capped.line))
183}
184
185pub fn read_log_lines_capped<R: BufRead>(
193 r: R,
194 max_bytes: usize,
195) -> impl Iterator<Item = io::Result<CappedLine>> {
196 read_lines(r, Some(max_bytes))
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201#[non_exhaustive]
202pub struct LineRead {
203 pub line_bytes: usize,
206 pub terminated: bool,
209}
210
211pub fn read_log_line_capped<R: BufRead>(
219 r: &mut R,
220 max_bytes: usize,
221 buf: &mut Vec<u8>,
222) -> io::Result<Option<LineRead>> {
223 let mut line_bytes = 0;
224 let mut saw_bytes = false;
225 let mut terminated = false;
226 loop {
227 let available = match r.fill_buf() {
228 Ok(chunk) => chunk,
229 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
230 Err(e) => return Err(e),
231 };
232 if available.is_empty() {
233 break;
234 }
235 saw_bytes = true;
236 let (content, consumed, done) = match available.iter().position(|&b| b == b'\n') {
237 Some(i) => (&available[..i], i + 1, true),
238 None => (available, available.len(), false),
239 };
240 let room = max_bytes.saturating_sub(buf.len()).min(content.len());
241 buf.extend_from_slice(&content[..room]);
242 line_bytes += content.len();
243 r.consume(consumed);
244 if done {
245 terminated = true;
246 break;
247 }
248 }
249 Ok(saw_bytes.then_some(LineRead {
250 line_bytes,
251 terminated,
252 }))
253}
254
255pub fn trim_capped_tail(buf: &mut Vec<u8>) {
258 buf.truncate(buf.len() - incomplete_tail_len(buf));
259}
260
261fn read_lines<R: BufRead>(
262 mut r: R,
263 cap: Option<usize>,
264) -> impl Iterator<Item = io::Result<CappedLine>> {
265 let max = cap.unwrap_or(usize::MAX);
266 let mut buf = Vec::new();
267 std::iter::from_fn(move || {
268 buf.clear();
269 match read_log_line_capped(&mut r, max, &mut buf) {
270 Ok(None) => None,
271 Ok(Some(read)) => {
272 let over_cap = (read.line_bytes > max).then(|| {
273 trim_capped_tail(&mut buf);
274 OverCap {
275 cap: max,
276 line_bytes: read.line_bytes,
277 }
278 });
279 Some(Ok(CappedLine {
280 line: decode_log_line(&buf),
281 over_cap,
282 }))
283 }
284 Err(e) => Some(Err(e)),
285 }
286 })
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::io::Cursor;
293
294 #[test]
295 fn production_pattern_is_truncated() {
296 assert_eq!(
298 classify_utf8(b"\xe2\x80\x32"),
299 Utf8Decode::TruncatedCodepoint { at: 0 }
300 );
301 }
302
303 #[test]
304 fn truncation_then_clean_tail_latches_truncated() {
305 let line = b"CHANNEL_UN\xe2\x80then a long clean ASCII tail follows here";
307 assert_eq!(
308 classify_utf8(line),
309 Utf8Decode::TruncatedCodepoint { at: 10 }
310 );
311 }
312
313 #[test]
314 fn incomplete_at_end_of_line_is_truncated() {
315 assert_eq!(
316 classify_utf8(b"\xe2\x80"),
317 Utf8Decode::TruncatedCodepoint { at: 0 }
318 );
319 assert_eq!(
320 classify_utf8(b"\xe2"),
321 Utf8Decode::TruncatedCodepoint { at: 0 }
322 );
323 }
324
325 #[test]
326 fn malformed_bytes_are_invalid() {
327 assert_eq!(classify_utf8(b"\xff"), Utf8Decode::InvalidBytes { at: 0 });
328 assert_eq!(classify_utf8(b"\x80"), Utf8Decode::InvalidBytes { at: 0 });
329 assert_eq!(
330 classify_utf8(b"\xc0\x80"),
331 Utf8Decode::InvalidBytes { at: 0 }
332 );
333 }
334
335 #[test]
336 fn genuine_wins_over_earlier_truncation() {
337 match classify_utf8(b"\xe2\x80\x32\xff") {
339 Utf8Decode::InvalidBytes { .. } => {}
340 other => panic!("expected InvalidBytes, got {other:?}"),
341 }
342 }
343
344 #[test]
345 fn clean_line_is_clean() {
346 assert_eq!(classify_utf8("héllo wörld".as_bytes()), Utf8Decode::Clean);
347 assert_eq!(classify_utf8(b"plain ascii"), Utf8Decode::Clean);
348 }
349
350 #[test]
351 fn a_cr_before_the_newline_survives_the_decode() {
352 let decoded = decode_log_line(b"v=0\r\n");
354 assert_eq!(decoded.text, "v=0\r");
355 assert_eq!(decoded.decode, Utf8Decode::Clean);
356 }
357
358 #[test]
359 fn truncate_at_char_boundary_shorter_input_unchanged() {
360 assert_eq!(truncate_at_char_boundary("abc", 80), "abc");
361 assert_eq!(truncate_at_char_boundary("abc", 3), "abc");
362 assert_eq!(truncate_at_char_boundary("", 0), "");
363 }
364
365 #[test]
366 fn truncate_at_char_boundary_ascii_cut() {
367 assert_eq!(truncate_at_char_boundary("abcdef", 4), "abcd");
368 }
369
370 #[test]
371 fn truncate_at_char_boundary_backs_off_multibyte() {
372 assert_eq!(truncate_at_char_boundary("abcéf", 4), "abc");
374 let s = "ab\u{fffd}cd";
376 assert_eq!(truncate_at_char_boundary(s, 3), "ab");
377 assert_eq!(truncate_at_char_boundary(s, 4), "ab");
378 assert_eq!(truncate_at_char_boundary(s, 5), "ab\u{fffd}");
379 }
380
381 #[test]
382 fn read_log_lines_reports_per_line_verdict() {
383 let mut buf = Vec::new();
384 buf.extend_from_slice(b"first clean line\n");
385 buf.extend_from_slice(b"bad\xe2\x80stuff\n");
386 buf.extend_from_slice(b"third clean line\n");
387
388 let lines: Vec<DecodedLine> = read_log_lines(Cursor::new(buf))
389 .map(|d| d.expect("no io error"))
390 .collect();
391
392 assert_eq!(lines.len(), 3);
393 assert_eq!(lines[0].decode, Utf8Decode::Clean);
394 assert_eq!(lines[2].decode, Utf8Decode::Clean);
395 match lines[1].decode {
396 Utf8Decode::TruncatedCodepoint { .. } => {}
397 ref other => panic!("expected TruncatedCodepoint, got {other:?}"),
398 }
399 assert!(lines[1].text.starts_with("bad"));
401 assert!(lines[1].text.contains('\u{fffd}'));
402 assert!(lines[1].text.ends_with("stuff"));
403 }
404
405 fn capped(input: &'static [u8], max_bytes: usize) -> Vec<CappedLine> {
406 read_log_lines_capped(Cursor::new(input), max_bytes)
407 .map(|d| d.expect("no io error"))
408 .collect()
409 }
410
411 #[test]
412 fn a_line_exactly_at_the_cap_is_kept_whole() {
413 let lines = capped(b"abcde\nfg\n", 5);
414 assert_eq!(lines[0].line.text, "abcde");
415 assert_eq!(lines[0].over_cap, None);
416 assert_eq!(lines[1].line.text, "fg");
417 assert_eq!(lines[1].over_cap, None);
418 }
419
420 #[test]
421 fn a_line_past_the_cap_reports_the_full_physical_length() {
422 let lines = capped(b"abcdef\n", 5);
423 assert_eq!(lines[0].line.text, "abcde");
424 assert_eq!(
425 lines[0].over_cap,
426 Some(OverCap {
427 cap: 5,
428 line_bytes: 6
429 })
430 );
431 }
432
433 #[test]
434 fn the_readers_own_cut_never_reads_as_a_logger_truncation() {
435 let lines = capped("abcéd\n".as_bytes(), 4);
437 assert_eq!(lines[0].line.text, "abc");
438 assert_eq!(lines[0].line.decode, Utf8Decode::Clean);
439 assert_eq!(lines[0].over_cap.expect("over cap").line_bytes, 6);
440 }
441
442 #[test]
443 fn a_logger_truncation_under_the_cap_is_still_reported() {
444 let lines = capped(b"bad\xe2\x80\n", 100);
445 assert_eq!(lines[0].over_cap, None);
446 assert!(matches!(
447 lines[0].line.decode,
448 Utf8Decode::TruncatedCodepoint { at: 3 }
449 ));
450 }
451
452 #[test]
453 fn consecutive_over_cap_lines_do_not_leak_bytes_into_each_other() {
454 let lines = capped(b"aaaaaaaa\nbbbbbbbb\ncc\n", 3);
455 assert_eq!(lines[0].line.text, "aaa");
456 assert_eq!(lines[1].line.text, "bbb");
457 assert_eq!(lines[2].line.text, "cc");
458 assert_eq!(lines[2].over_cap, None);
459 }
460
461 #[test]
462 fn a_final_line_without_a_newline_is_yielded() {
463 let lines = capped(b"first\nlast no newline", 3);
464 assert_eq!(lines.len(), 2);
465 assert_eq!(lines[1].line.text, "las");
466 assert_eq!(lines[1].over_cap.expect("over cap").line_bytes, 15);
467 }
468
469 #[test]
470 fn a_zero_cap_keeps_the_framing_and_drops_every_byte() {
471 let lines = capped(b"abc\n\ndef\n", 0);
472 assert_eq!(lines.len(), 3);
473 assert_eq!(lines[0].line.text, "");
474 assert_eq!(lines[0].over_cap.expect("over cap").line_bytes, 3);
475 assert_eq!(lines[1].over_cap, None);
477 }
478
479 #[test]
480 fn a_line_spanning_several_reader_refills_counts_every_byte() {
481 let input: Vec<u8> = {
483 let mut v = vec![b'x'; 500];
484 v.push(b'\n');
485 v
486 };
487 let reader = io::BufReader::with_capacity(16, Cursor::new(input));
488 let lines: Vec<CappedLine> = read_log_lines_capped(reader, 100)
489 .map(|d| d.expect("no io error"))
490 .collect();
491 assert_eq!(lines[0].line.text.len(), 100);
492 assert_eq!(lines[0].over_cap.expect("over cap").line_bytes, 500);
493 }
494}