provenant/utils/file/
encoding.rs1const BINARY_CONTROL_CHAR_THRESHOLD_DIVISOR: usize = 10;
11
12pub(super) const CORRUPTED_UTF16_BOM_PREFIX: &[u8] = &[0xEF, 0xBF, 0xBD, 0xEF, 0xBF, 0xBD];
13
14pub(super) const NEAR_BINARY_SKIP_DIAGNOSTIC: &str = "Text skipped from license/copyright detection: invalid UTF-8 with too many control bytes (likely binary)";
19
20pub fn decode_bytes_to_string(bytes: &[u8]) -> String {
27 decode_bytes_to_string_with_diagnostic(bytes).0
28}
29
30pub(super) fn decode_bytes_to_string_with_diagnostic(bytes: &[u8]) -> (String, Option<String>) {
36 if let Some(decoded) = decode_utf16_text(bytes) {
37 return (decoded, None);
38 }
39
40 match String::from_utf8(bytes.to_vec()) {
41 Ok(s) => (s, None),
42 Err(e) => {
43 let bytes = e.into_bytes();
44 if has_binary_control_chars(&bytes) {
45 return (String::new(), Some(NEAR_BINARY_SKIP_DIAGNOSTIC.to_string()));
46 }
47 (bytes.iter().map(|&b| b as char).collect(), None)
48 }
49 }
50}
51
52pub(super) fn is_utf8_text(bytes: &[u8]) -> bool {
53 std::str::from_utf8(bytes).is_ok()
54}
55
56fn strip_corrupted_utf16_bom_prefix(bytes: &[u8]) -> &[u8] {
57 bytes
58 .strip_prefix(CORRUPTED_UTF16_BOM_PREFIX)
59 .unwrap_or(bytes)
60}
61
62fn decode_utf16_units(bytes: &[u8], is_le: bool, require_text_shape: bool) -> Option<String> {
63 if bytes.is_empty() || !bytes.len().is_multiple_of(2) {
64 return None;
65 }
66
67 let code_units: Vec<u16> = bytes
68 .chunks_exact(2)
69 .map(|chunk| {
70 if is_le {
71 u16::from_le_bytes([chunk[0], chunk[1]])
72 } else {
73 u16::from_be_bytes([chunk[0], chunk[1]])
74 }
75 })
76 .collect();
77
78 let decoded = std::char::decode_utf16(code_units)
79 .collect::<Result<String, _>>()
80 .ok()?;
81
82 if !require_text_shape {
83 return (!decoded.contains('\0')).then_some(decoded);
84 }
85
86 if !looks_like_decoded_text(&decoded) {
87 return None;
88 }
89
90 Some(decoded)
91}
92
93pub(super) fn looks_like_decoded_text(decoded: &str) -> bool {
94 if decoded
95 .chars()
96 .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
97 {
98 return false;
99 }
100
101 let visible = decoded
102 .chars()
103 .filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t'))
104 .count();
105 if visible < 3 || decoded.contains('\0') {
106 return false;
107 }
108
109 let alpha = decoded.chars().filter(|ch| ch.is_alphabetic()).count();
110 let punctuation = decoded
111 .chars()
112 .filter(|ch| {
113 matches!(
114 ch,
115 '{' | '}'
116 | '['
117 | ']'
118 | '<'
119 | '>'
120 | '('
121 | ')'
122 | ':'
123 | ';'
124 | ','
125 | '"'
126 | '\''
127 | '/'
128 | '='
129 | '-'
130 | '_'
131 | '#'
132 | '!'
133 )
134 })
135 .count();
136 let whitespace = decoded.chars().filter(|ch| ch.is_whitespace()).count();
137
138 let textish = alpha + punctuation + whitespace;
139 textish + (visible / 5) >= visible && (alpha > 0 || punctuation >= 2)
140}
141
142fn detect_utf16_endianness(bytes: &[u8]) -> Option<bool> {
143 let stripped = strip_corrupted_utf16_bom_prefix(bytes);
144 if stripped.len() < 4 || !stripped.len().is_multiple_of(2) {
145 return None;
146 }
147
148 let pair_count = stripped.len() / 2;
149 let even_zero = stripped.iter().step_by(2).filter(|&&b| b == 0).count();
150 let odd_zero = stripped
151 .iter()
152 .skip(1)
153 .step_by(2)
154 .filter(|&&b| b == 0)
155 .count();
156
157 let looks_like_be = even_zero * 3 >= pair_count && odd_zero * 6 <= pair_count;
158 let looks_like_le = odd_zero * 3 >= pair_count && even_zero * 6 <= pair_count;
159
160 match (looks_like_le, looks_like_be) {
161 (true, false) => Some(true),
162 (false, true) => Some(false),
163 (true, true) => Some(true),
164 (false, false) => None,
165 }
166}
167
168pub(super) fn decode_utf16_text(bytes: &[u8]) -> Option<String> {
169 if let Some(decoded) = decode_utf16_bom_text(bytes) {
170 return Some(decoded);
171 }
172
173 let stripped = strip_corrupted_utf16_bom_prefix(bytes);
174 match detect_utf16_endianness(bytes) {
175 Some(true) => decode_utf16_units(stripped, true, true),
176 Some(false) => decode_utf16_units(stripped, false, true),
177 None => None,
178 }
179}
180
181pub(super) fn decode_utf16_json_text(bytes: &[u8]) -> Option<String> {
182 if bytes.len() >= 2 {
183 let (is_le, body) = match bytes {
184 [0xFF, 0xFE, rest @ ..] => (true, rest),
185 [0xFE, 0xFF, rest @ ..] => (false, rest),
186 _ => {
187 let stripped = strip_corrupted_utf16_bom_prefix(bytes);
188 return match detect_utf16_endianness(bytes) {
189 Some(true) => decode_utf16_units(stripped, true, false),
190 Some(false) => decode_utf16_units(stripped, false, false),
191 None => None,
192 };
193 }
194 };
195
196 if body.is_empty() || !body.len().is_multiple_of(2) {
197 return None;
198 }
199
200 return decode_utf16_units(body, is_le, false);
201 }
202
203 None
204}
205
206fn decode_utf16_bom_text(bytes: &[u8]) -> Option<String> {
207 if bytes.len() < 2 || !bytes.len().is_multiple_of(2) {
208 return None;
209 }
210
211 let (is_le, body) = match bytes {
212 [0xFF, 0xFE, rest @ ..] => (true, rest),
213 [0xFE, 0xFF, rest @ ..] => (false, rest),
214 _ => return None,
215 };
216
217 if body.is_empty() || body.len() % 2 != 0 {
218 return None;
219 }
220
221 decode_utf16_units(body, is_le, true)
222}
223
224pub(super) fn has_binary_control_chars(bytes: &[u8]) -> bool {
225 let control_count = bytes
226 .iter()
227 .filter(|&&b| b < 0x09 || (b > 0x0D && b < 0x20))
228 .count();
229 control_count > bytes.len() / BINARY_CONTROL_CHAR_THRESHOLD_DIVISOR
230}
231
232pub(super) fn has_decodable_text(bytes: &[u8]) -> bool {
233 bytes.is_empty()
234 || is_utf8_text(bytes)
235 || decode_utf16_text(bytes).is_some()
236 || !has_binary_control_chars(bytes)
237}
238
239pub(super) fn looks_like_textual_bytes(bytes: &[u8]) -> bool {
240 if bytes.is_empty() || is_utf8_text(bytes) {
241 return true;
242 }
243 if let Some(decoded) = decode_utf16_text(bytes) {
244 return decoded
245 .chars()
246 .any(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t'));
247 }
248
249 let printable_count = bytes
250 .iter()
251 .filter(|&&b| matches!(b, b'\n' | b'\r' | b'\t') || (0x20..=0x7e).contains(&b))
252 .count();
253 printable_count * 2 >= bytes.len()
254}