Skip to main content

freeswitch_log_parser/
decode.rs

1//! Layer 0 — byte→line decoding with the truncated-UTF-8 case typed distinctly
2//! from genuine corruption.
3//!
4//! `mod_logfile`'s 2 KiB buffer can chop a multi-byte codepoint mid-character,
5//! leaving an incomplete sequence in the byte stream — the byte-level twin of the
6//! record-level collision [`ParseStats::lines_split`](crate::ParseStats::lines_split)
7//! already models. This layer owns `read_until(b'\n')` so byte→line decoding lives
8//! in one place, and reports the truncated case as a benign, recoverable outcome
9//! rather than an opaque `io::Error`.
10
11use std::io::{self, BufRead};
12
13/// Outcome of classifying a line's bytes as UTF-8.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Utf8Decode {
17    /// Valid UTF-8 throughout.
18    Clean,
19    /// One or more incomplete multibyte sequences — `mod_logfile`'s 2 KiB buffer
20    /// chopping a codepoint mid-character. Benign and recoverable. `at` is the
21    /// byte offset of the first truncation.
22    TruncatedCodepoint { at: usize },
23    /// A byte that cannot be part of any UTF-8 sequence — genuine corruption.
24    /// `at` is the byte offset of the first invalid byte.
25    InvalidBytes { at: usize },
26}
27
28/// A decoded log line plus the UTF-8 verdict for its bytes.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DecodedLine {
31    /// Lossy-recovered text (U+FFFD for invalid bytes) so the record is preserved.
32    /// The newline is gone; a CR before it is not.
33    pub text: String,
34    pub decode: Utf8Decode,
35}
36
37/// Classify a line's bytes as UTF-8, distinguishing a truncated codepoint (benign)
38/// from a genuinely invalid byte (corruption). Pure; no I/O.
39pub fn classify_utf8(line: &[u8]) -> Utf8Decode {
40    let mut rest = line;
41    let mut base = 0;
42    // Latch: first truncation skipped, so an Ok reached after a clean tail still
43    // reports TruncatedCodepoint rather than Clean.
44    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
77/// Length of the trailing incomplete UTF-8 sequence in `bytes`, if any.
78///
79/// A reader cutting at a byte bound must drop those bytes before decoding, or
80/// its own cut is reported as [`Utf8Decode::TruncatedCodepoint`] and reads as
81/// `mod_logfile` having chopped a character.
82fn 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
92/// Valid lead byte followed only by valid continuations, shorter than the lead
93/// requires — i.e. a codepoint cut short rather than a malformed encoding.
94fn 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
107/// Largest prefix of `s` at most `max_bytes` long that ends on a char boundary.
108///
109/// Byte-index truncation (`&s[..n]`) panics when `n` lands inside a multi-byte
110/// codepoint — log content carries real UTF-8 and the lossy decode inserts
111/// 3-byte U+FFFD replacements. Use this wherever text of unknown content is
112/// shortened for display or diagnostics.
113pub 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
124/// Decode one raw log line: drop the newline, classify, lossy-recover.
125///
126/// For readers that own their read loop. A `tail -f` follower cannot use
127/// [`read_log_lines`], which ends at EOF rather than waiting for more bytes, but
128/// must classify identically instead of re-deriving this decision.
129///
130/// A CR before the newline is kept. [`LogStream`](crate::LogStream) counts a
131/// line's bytes against `mod_logfile`'s write budget, so a byte dropped here is
132/// one it cannot count; it trims the CR itself, after counting.
133pub 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/// How much of a physical line the reader's byte bound cost.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct OverCap {
148    /// The bound the reader was given.
149    pub cap: usize,
150    /// Length of the whole physical line, newline excluded. What the consumer
151    /// lost is `line_bytes` minus the decoded text's byte length.
152    pub line_bytes: usize,
153}
154
155impl OverCap {
156    /// For a reader owning its read loop, which reaches the verdict itself.
157    pub fn new(cap: usize, line_bytes: usize) -> Self {
158        OverCap { cap, line_bytes }
159    }
160}
161
162/// A decoded line plus whether the reader's byte bound cut it.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct CappedLine {
165    pub line: DecodedLine,
166    /// `Some` when the physical line ran past the bound. The discarded tail may
167    /// be an intact record — a verbatim write carries no budget — so this is a
168    /// count, not a claim of damage.
169    pub over_cap: Option<OverCap>,
170}
171
172/// Read newline-delimited log lines, decoding each with the truncated-codepoint
173/// case typed distinctly from corruption.
174///
175/// Real I/O errors stay terminal `io::Error`. UTF-8 invalidity is **not** an
176/// error — the line is lossy-recovered (U+FFFD) so the record survives, and the
177/// verdict is reported in [`DecodedLine::decode`].
178///
179/// Peak memory is whatever the longest line in the input turns out to be.
180/// [`read_log_lines_capped`] takes a bound instead.
181pub 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
185/// Read newline-delimited log lines, never materializing more than `max_bytes`
186/// of any one of them.
187///
188/// A line past the bound is decoded up to it and the rest discarded while the
189/// reader scans on to the newline, so a consumer's peak memory is its own choice
190/// rather than the longest line the file happens to hold. Every discard is
191/// reported in [`CappedLine::over_cap`] with the physical line's full length.
192pub 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/// What one [`read_log_line_capped`] call took from the reader.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201#[non_exhaustive]
202pub struct LineRead {
203    /// Bytes of line content this call passed, newline excluded — including any
204    /// the cap kept out of the buffer.
205    pub line_bytes: usize,
206    /// Whether a newline ended the line. `false` means the input ran out first,
207    /// which for a follower means the writer has not finished the record.
208    pub terminated: bool,
209}
210
211/// Append up to `max_bytes` of the next line to `buf`, consuming the rest of the
212/// line from `r` regardless. `None` when the reader was already at EOF.
213///
214/// For readers that own their read loop: a `tail -f` follower resumes an
215/// unterminated line by calling again with the same `buf`, so `max_bytes` is
216/// measured against what `buf` already holds. Everything else wants
217/// [`read_log_lines_capped`], which owns the loop.
218pub 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
255/// Drop a trailing sequence the caller's own byte cut left incomplete, so the
256/// decode verdict names only what the logger truncated.
257pub 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        // e2 80 then ASCII '2' — valid lead + continuation, cut short by a record splice.
297        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        // The Ok-after-skip latch: a clean tail must not erase the earlier truncation.
306        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        // Truncation first, then a genuine bad byte — corruption must win.
338        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        // LogStream counts these bytes against the write budget, then trims.
353        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        // 'é' spans bytes 3-4: a 4-byte cut must back off to 3.
373        assert_eq!(truncate_at_char_boundary("abcéf", 4), "abc");
374        // U+FFFD is 3 bytes.
375        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        // Record preserved with U+FFFD standing in for the chopped codepoint.
400        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        // Cap lands between the two bytes of 'é'.
436        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        // An empty line has nothing to lose, so no cap verdict.
476        assert_eq!(lines[1].over_cap, None);
477    }
478
479    #[test]
480    fn a_line_spanning_several_reader_refills_counts_every_byte() {
481        // BufReader with a tiny buffer forces fill_buf to hand back fragments.
482        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}