freeswitch_log_parser/
decode.rs1use std::io::{self, BufRead};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum Utf8Decode {
17 Clean,
19 TruncatedCodepoint { at: usize },
23 InvalidBytes { at: usize },
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DecodedLine {
31 pub text: String,
33 pub decode: Utf8Decode,
34}
35
36pub fn classify_utf8(line: &[u8]) -> Utf8Decode {
39 let mut rest = line;
40 let mut base = 0;
41 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
76fn 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
91pub 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
108pub 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 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 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 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 assert_eq!(truncate_at_char_boundary("abcéf", 4), "abc");
212 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 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}