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/// Read newline-delimited log lines, decoding each with the truncated-codepoint
109/// case typed distinctly from corruption.
110///
111/// Real I/O errors stay terminal `io::Error`. UTF-8 invalidity is **not** an
112/// error — the line is lossy-recovered (U+FFFD) so the record survives, and the
113/// verdict is reported in [`DecodedLine::decode`].
114pub fn read_log_lines<R: BufRead>(mut r: R) -> impl Iterator<Item = io::Result<DecodedLine>> {
115    std::iter::from_fn(move || {
116        let mut buf = Vec::new();
117        match r.read_until(b'\n', &mut buf) {
118            Ok(0) => None,
119            Ok(_) => {
120                if buf.last() == Some(&b'\n') {
121                    buf.pop();
122                    if buf.last() == Some(&b'\r') {
123                        buf.pop();
124                    }
125                }
126                let decode = classify_utf8(&buf);
127                let text = String::from_utf8_lossy(&buf).into_owned();
128                Some(Ok(DecodedLine { text, decode }))
129            }
130            Err(e) => Some(Err(e)),
131        }
132    })
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::io::Cursor;
139
140    #[test]
141    fn production_pattern_is_truncated() {
142        // e2 80 then ASCII '2' — valid lead + continuation, cut short by a record splice.
143        assert_eq!(
144            classify_utf8(b"\xe2\x80\x32"),
145            Utf8Decode::TruncatedCodepoint { at: 0 }
146        );
147    }
148
149    #[test]
150    fn truncation_then_clean_tail_latches_truncated() {
151        // The Ok-after-skip latch: a clean tail must not erase the earlier truncation.
152        let line = b"CHANNEL_UN\xe2\x80then a long clean ASCII tail follows here";
153        assert_eq!(
154            classify_utf8(line),
155            Utf8Decode::TruncatedCodepoint { at: 10 }
156        );
157    }
158
159    #[test]
160    fn incomplete_at_end_of_line_is_truncated() {
161        assert_eq!(
162            classify_utf8(b"\xe2\x80"),
163            Utf8Decode::TruncatedCodepoint { at: 0 }
164        );
165        assert_eq!(
166            classify_utf8(b"\xe2"),
167            Utf8Decode::TruncatedCodepoint { at: 0 }
168        );
169    }
170
171    #[test]
172    fn malformed_bytes_are_invalid() {
173        assert_eq!(classify_utf8(b"\xff"), Utf8Decode::InvalidBytes { at: 0 });
174        assert_eq!(classify_utf8(b"\x80"), Utf8Decode::InvalidBytes { at: 0 });
175        assert_eq!(
176            classify_utf8(b"\xc0\x80"),
177            Utf8Decode::InvalidBytes { at: 0 }
178        );
179    }
180
181    #[test]
182    fn genuine_wins_over_earlier_truncation() {
183        // Truncation first, then a genuine bad byte — corruption must win.
184        match classify_utf8(b"\xe2\x80\x32\xff") {
185            Utf8Decode::InvalidBytes { .. } => {}
186            other => panic!("expected InvalidBytes, got {other:?}"),
187        }
188    }
189
190    #[test]
191    fn clean_line_is_clean() {
192        assert_eq!(classify_utf8("héllo wörld".as_bytes()), Utf8Decode::Clean);
193        assert_eq!(classify_utf8(b"plain ascii"), Utf8Decode::Clean);
194    }
195
196    #[test]
197    fn truncate_at_char_boundary_shorter_input_unchanged() {
198        assert_eq!(truncate_at_char_boundary("abc", 80), "abc");
199        assert_eq!(truncate_at_char_boundary("abc", 3), "abc");
200        assert_eq!(truncate_at_char_boundary("", 0), "");
201    }
202
203    #[test]
204    fn truncate_at_char_boundary_ascii_cut() {
205        assert_eq!(truncate_at_char_boundary("abcdef", 4), "abcd");
206    }
207
208    #[test]
209    fn truncate_at_char_boundary_backs_off_multibyte() {
210        // 'é' spans bytes 3-4: a 4-byte cut must back off to 3.
211        assert_eq!(truncate_at_char_boundary("abcéf", 4), "abc");
212        // U+FFFD is 3 bytes.
213        let s = "ab\u{fffd}cd";
214        assert_eq!(truncate_at_char_boundary(s, 3), "ab");
215        assert_eq!(truncate_at_char_boundary(s, 4), "ab");
216        assert_eq!(truncate_at_char_boundary(s, 5), "ab\u{fffd}");
217    }
218
219    #[test]
220    fn read_log_lines_reports_per_line_verdict() {
221        let mut buf = Vec::new();
222        buf.extend_from_slice(b"first clean line\n");
223        buf.extend_from_slice(b"bad\xe2\x80stuff\n");
224        buf.extend_from_slice(b"third clean line\n");
225
226        let lines: Vec<DecodedLine> = read_log_lines(Cursor::new(buf))
227            .map(|d| d.expect("no io error"))
228            .collect();
229
230        assert_eq!(lines.len(), 3);
231        assert_eq!(lines[0].decode, Utf8Decode::Clean);
232        assert_eq!(lines[2].decode, Utf8Decode::Clean);
233        match lines[1].decode {
234            Utf8Decode::TruncatedCodepoint { .. } => {}
235            ref other => panic!("expected TruncatedCodepoint, got {other:?}"),
236        }
237        // Record preserved with U+FFFD standing in for the chopped codepoint.
238        assert!(lines[1].text.starts_with("bad"));
239        assert!(lines[1].text.contains('\u{fffd}'));
240        assert!(lines[1].text.ends_with("stuff"));
241    }
242}