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/// Valid lead byte followed only by valid continuations, shorter than the lead
78/// requires — i.e. a codepoint cut short rather than a malformed encoding.
79fn is_incomplete_multibyte(seq: &[u8]) -> bool {
80    let Some((&lead, cont)) = seq.split_first() else {
81        return false;
82    };
83    let need = match lead {
84        0xC2..=0xDF => 2,
85        0xE0..=0xEF => 3,
86        0xF0..=0xF4 => 4,
87        _ => return false,
88    };
89    seq.len() < need && cont.iter().all(|&b| (0x80..=0xBF).contains(&b))
90}
91
92/// Largest prefix of `s` at most `max_bytes` long that ends on a char boundary.
93///
94/// Byte-index truncation (`&s[..n]`) panics when `n` lands inside a multi-byte
95/// codepoint — log content carries real UTF-8 and the lossy decode inserts
96/// 3-byte U+FFFD replacements. Use this wherever text of unknown content is
97/// shortened for display or diagnostics.
98pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
99    if s.len() <= max_bytes {
100        return s;
101    }
102    let mut end = max_bytes;
103    while !s.is_char_boundary(end) {
104        end -= 1;
105    }
106    &s[..end]
107}
108
109/// Decode one raw log line: drop the newline, classify, lossy-recover.
110///
111/// For readers that own their read loop. A `tail -f` follower cannot use
112/// [`read_log_lines`], which ends at EOF rather than waiting for more bytes, but
113/// must classify identically instead of re-deriving this decision.
114///
115/// A CR before the newline is kept. [`LogStream`](crate::LogStream) counts a
116/// line's bytes against `mod_logfile`'s write budget, so a byte dropped here is
117/// one it cannot count; it trims the CR itself, after counting.
118pub fn decode_log_line(bytes: &[u8]) -> DecodedLine {
119    let mut buf = bytes;
120    if let Some((&b'\n', rest)) = buf.split_last() {
121        buf = rest;
122    }
123    DecodedLine {
124        text: String::from_utf8_lossy(buf).into_owned(),
125        decode: classify_utf8(buf),
126    }
127}
128
129/// Read newline-delimited log lines, decoding each with the truncated-codepoint
130/// case typed distinctly from corruption.
131///
132/// Real I/O errors stay terminal `io::Error`. UTF-8 invalidity is **not** an
133/// error — the line is lossy-recovered (U+FFFD) so the record survives, and the
134/// verdict is reported in [`DecodedLine::decode`].
135pub fn read_log_lines<R: BufRead>(mut r: R) -> impl Iterator<Item = io::Result<DecodedLine>> {
136    std::iter::from_fn(move || {
137        let mut buf = Vec::new();
138        match r.read_until(b'\n', &mut buf) {
139            Ok(0) => None,
140            Ok(_) => Some(Ok(decode_log_line(&buf))),
141            Err(e) => Some(Err(e)),
142        }
143    })
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::io::Cursor;
150
151    #[test]
152    fn production_pattern_is_truncated() {
153        // e2 80 then ASCII '2' — valid lead + continuation, cut short by a record splice.
154        assert_eq!(
155            classify_utf8(b"\xe2\x80\x32"),
156            Utf8Decode::TruncatedCodepoint { at: 0 }
157        );
158    }
159
160    #[test]
161    fn truncation_then_clean_tail_latches_truncated() {
162        // The Ok-after-skip latch: a clean tail must not erase the earlier truncation.
163        let line = b"CHANNEL_UN\xe2\x80then a long clean ASCII tail follows here";
164        assert_eq!(
165            classify_utf8(line),
166            Utf8Decode::TruncatedCodepoint { at: 10 }
167        );
168    }
169
170    #[test]
171    fn incomplete_at_end_of_line_is_truncated() {
172        assert_eq!(
173            classify_utf8(b"\xe2\x80"),
174            Utf8Decode::TruncatedCodepoint { at: 0 }
175        );
176        assert_eq!(
177            classify_utf8(b"\xe2"),
178            Utf8Decode::TruncatedCodepoint { at: 0 }
179        );
180    }
181
182    #[test]
183    fn malformed_bytes_are_invalid() {
184        assert_eq!(classify_utf8(b"\xff"), Utf8Decode::InvalidBytes { at: 0 });
185        assert_eq!(classify_utf8(b"\x80"), Utf8Decode::InvalidBytes { at: 0 });
186        assert_eq!(
187            classify_utf8(b"\xc0\x80"),
188            Utf8Decode::InvalidBytes { at: 0 }
189        );
190    }
191
192    #[test]
193    fn genuine_wins_over_earlier_truncation() {
194        // Truncation first, then a genuine bad byte — corruption must win.
195        match classify_utf8(b"\xe2\x80\x32\xff") {
196            Utf8Decode::InvalidBytes { .. } => {}
197            other => panic!("expected InvalidBytes, got {other:?}"),
198        }
199    }
200
201    #[test]
202    fn clean_line_is_clean() {
203        assert_eq!(classify_utf8("héllo wörld".as_bytes()), Utf8Decode::Clean);
204        assert_eq!(classify_utf8(b"plain ascii"), Utf8Decode::Clean);
205    }
206
207    #[test]
208    fn a_cr_before_the_newline_survives_the_decode() {
209        // LogStream counts these bytes against the write budget, then trims.
210        let decoded = decode_log_line(b"v=0\r\n");
211        assert_eq!(decoded.text, "v=0\r");
212        assert_eq!(decoded.decode, Utf8Decode::Clean);
213    }
214
215    #[test]
216    fn truncate_at_char_boundary_shorter_input_unchanged() {
217        assert_eq!(truncate_at_char_boundary("abc", 80), "abc");
218        assert_eq!(truncate_at_char_boundary("abc", 3), "abc");
219        assert_eq!(truncate_at_char_boundary("", 0), "");
220    }
221
222    #[test]
223    fn truncate_at_char_boundary_ascii_cut() {
224        assert_eq!(truncate_at_char_boundary("abcdef", 4), "abcd");
225    }
226
227    #[test]
228    fn truncate_at_char_boundary_backs_off_multibyte() {
229        // 'é' spans bytes 3-4: a 4-byte cut must back off to 3.
230        assert_eq!(truncate_at_char_boundary("abcéf", 4), "abc");
231        // U+FFFD is 3 bytes.
232        let s = "ab\u{fffd}cd";
233        assert_eq!(truncate_at_char_boundary(s, 3), "ab");
234        assert_eq!(truncate_at_char_boundary(s, 4), "ab");
235        assert_eq!(truncate_at_char_boundary(s, 5), "ab\u{fffd}");
236    }
237
238    #[test]
239    fn read_log_lines_reports_per_line_verdict() {
240        let mut buf = Vec::new();
241        buf.extend_from_slice(b"first clean line\n");
242        buf.extend_from_slice(b"bad\xe2\x80stuff\n");
243        buf.extend_from_slice(b"third clean line\n");
244
245        let lines: Vec<DecodedLine> = read_log_lines(Cursor::new(buf))
246            .map(|d| d.expect("no io error"))
247            .collect();
248
249        assert_eq!(lines.len(), 3);
250        assert_eq!(lines[0].decode, Utf8Decode::Clean);
251        assert_eq!(lines[2].decode, Utf8Decode::Clean);
252        match lines[1].decode {
253            Utf8Decode::TruncatedCodepoint { .. } => {}
254            ref other => panic!("expected TruncatedCodepoint, got {other:?}"),
255        }
256        // Record preserved with U+FFFD standing in for the chopped codepoint.
257        assert!(lines[1].text.starts_with("bad"));
258        assert!(lines[1].text.contains('\u{fffd}'));
259        assert!(lines[1].text.ends_with("stuff"));
260    }
261}