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 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 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 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 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 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}