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