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