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,
34 pub decode: Utf8Decode,
35}
36
37pub fn classify_utf8(line: &[u8]) -> Utf8Decode {
40 let mut rest = line;
41 let mut base = 0;
42 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
77fn 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
92pub 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
109pub 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
129pub 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 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 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 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 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 assert_eq!(truncate_at_char_boundary("abcéf", 4), "abc");
231 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 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}