1pub const CLASSIFIER_SAMPLE_SIZE: usize = 4 * 1024;
37
38const LOW_ENTROPY_THRESHOLD: f32 = 0.5;
40const HIGH_ENTROPY_THRESHOLD: f32 = 7.5;
43const INCOMPRESSIBLE_THRESHOLD: f32 = 6.5;
49const SPARSE_ZERO_RATIO_THRESHOLD: f32 = 0.8;
51const TEXT_PRINTABLE_RATIO_THRESHOLD: f32 = 0.85;
53
54#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
58pub enum Class {
59 Text,
60 Code,
61 Binary,
62 Compressed,
63 Media,
64 Sparse,
65 Incompressible,
70}
71
72impl Class {
73 #[must_use]
76 pub const fn to_id(self) -> u8 {
77 match self {
78 Self::Text => 0x01,
79 Self::Code => 0x02,
80 Self::Binary => 0x03,
81 Self::Compressed => 0x04,
82 Self::Media => 0x05,
83 Self::Sparse => 0x06,
84 Self::Incompressible => 0x07,
85 }
86 }
87
88 #[must_use]
90 pub const fn as_str(self) -> &'static str {
91 match self {
92 Self::Text => "text",
93 Self::Code => "code",
94 Self::Binary => "binary",
95 Self::Compressed => "compressed",
96 Self::Media => "media",
97 Self::Sparse => "sparse",
98 Self::Incompressible => "incompressible",
99 }
100 }
101}
102
103#[derive(Copy, Clone, Debug, Default)]
107pub struct Classifier;
108
109impl Classifier {
110 #[must_use]
113 pub fn classify(&self, data: &[u8]) -> Class {
114 if data.is_empty() {
115 return Class::Sparse;
116 }
117 let sample = if data.len() <= CLASSIFIER_SAMPLE_SIZE {
118 data
119 } else {
120 &data[..CLASSIFIER_SAMPLE_SIZE]
121 };
122 if let Some(class) = detect_magic(sample) {
124 return class;
125 }
126 let entropy = shannon_entropy(sample);
127 let zero_ratio = zero_byte_ratio(sample);
128 if entropy < LOW_ENTROPY_THRESHOLD && zero_ratio > SPARSE_ZERO_RATIO_THRESHOLD {
129 return Class::Sparse;
130 }
131 if entropy >= HIGH_ENTROPY_THRESHOLD {
132 return Class::Incompressible;
136 }
137 if entropy >= INCOMPRESSIBLE_THRESHOLD {
138 return Class::Incompressible;
142 }
143 let printable_ratio = printable_ascii_ratio(sample);
144 if printable_ratio > TEXT_PRINTABLE_RATIO_THRESHOLD {
145 return Class::Text;
146 }
147 Class::Binary
148 }
149}
150
151fn detect_magic(data: &[u8]) -> Option<Class> {
154 if data.starts_with(&[0x1F, 0x8B]) {
155 return Some(Class::Compressed); }
157 if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
158 return Some(Class::Compressed); }
160 if data.starts_with(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) {
161 return Some(Class::Compressed); }
163 if data.starts_with(&[0x42, 0x5A, 0x68]) {
164 return Some(Class::Compressed); }
166 if data.starts_with(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) {
167 return Some(Class::Compressed); }
169 if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
170 return Some(Class::Media); }
172 if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
173 return Some(Class::Media); }
175 if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
176 return Some(Class::Media); }
178 if data.starts_with(&[0x52, 0x49, 0x46, 0x46]) && data.len() >= 12 && &data[8..12] == b"WEBP" {
179 return Some(Class::Media); }
181 if data.starts_with(&[0xFF, 0xFB]) || data.starts_with(&[0x49, 0x44, 0x33]) {
182 return Some(Class::Media); }
184 if data.starts_with(&[0x66, 0x4C, 0x61, 0x43]) {
185 return Some(Class::Media); }
187 if data.len() >= 12 && &data[4..8] == b"ftyp" {
188 return Some(Class::Media); }
190 if data.starts_with(&[0x4F, 0x67, 0x67, 0x53]) {
191 return Some(Class::Media); }
193 if data.starts_with(&[0x7F, 0x45, 0x4C, 0x46]) {
194 return Some(Class::Code); }
196 if data.len() >= 4
197 && (data.starts_with(&[0xFE, 0xED, 0xFA, 0xCE])
198 || data.starts_with(&[0xFE, 0xED, 0xFA, 0xCF])
199 || data.starts_with(&[0xCE, 0xFA, 0xED, 0xFE])
200 || data.starts_with(&[0xCF, 0xFA, 0xED, 0xFE]))
201 {
202 return Some(Class::Code); }
204 if data.len() >= 2 && data[0] == 0x4D && data[1] == 0x5A {
205 return Some(Class::Code); }
207 None
208}
209
210#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
216fn shannon_entropy(data: &[u8]) -> f32 {
217 if data.is_empty() {
218 return 0.0;
219 }
220 let mut counts = [0u32; 256];
221 for &b in data {
222 counts[usize::from(b)] += 1;
223 }
224 let total = data.len() as f64;
225 let mut entropy = 0.0_f64;
226 for &count in &counts {
227 if count == 0 {
228 continue;
229 }
230 let p = f64::from(count) / total;
231 entropy -= p * p.log2();
232 }
233 entropy as f32
234}
235
236#[allow(
238 clippy::cast_precision_loss,
239 clippy::naive_bytecount,
240 clippy::cast_possible_truncation
241)]
242fn zero_byte_ratio(data: &[u8]) -> f32 {
243 if data.is_empty() {
244 return 0.0;
245 }
246 let zeros = data.iter().filter(|&&b| b == 0).count();
247 (zeros as f64 / data.len() as f64) as f32
248}
249
250#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
253fn printable_ascii_ratio(data: &[u8]) -> f32 {
254 if data.is_empty() {
255 return 0.0;
256 }
257 let printable = data
258 .iter()
259 .filter(|&&b| (0x20..=0x7E).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t')
260 .count();
261 (printable as f64 / data.len() as f64) as f32
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn empty_classifies_as_sparse() {
270 assert_eq!(Classifier.classify(&[]), Class::Sparse);
271 }
272
273 #[test]
274 fn gzip_magic_wins_over_entropy() {
275 let mut data = vec![0x1F, 0x8B, 0x08];
276 data.extend(std::iter::repeat(0).take(100));
277 assert_eq!(Classifier.classify(&data), Class::Compressed);
278 }
279
280 #[test]
281 fn zstd_magic_detected() {
282 let data = [0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00];
283 assert_eq!(Classifier.classify(&data), Class::Compressed);
284 }
285
286 #[test]
287 fn xz_magic_detected() {
288 let data = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x00, 0x00];
289 assert_eq!(Classifier.classify(&data), Class::Compressed);
290 }
291
292 #[test]
293 fn jpeg_magic_detected() {
294 let data = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
295 assert_eq!(Classifier.classify(&data), Class::Media);
296 }
297
298 #[test]
299 fn png_magic_detected() {
300 let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
301 assert_eq!(Classifier.classify(&data), Class::Media);
302 }
303
304 #[test]
305 fn gif_magic_detected() {
306 assert_eq!(Classifier.classify(b"GIF89a..."), Class::Media);
307 }
308
309 #[test]
310 fn webp_magic_detected() {
311 let data = [
312 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, b'W', b'E', b'B', b'P',
313 ];
314 assert_eq!(Classifier.classify(&data), Class::Media);
315 }
316
317 #[test]
318 fn mp3_id3_magic_detected() {
319 let data = [b'I', b'D', b'3', 0x03, 0x00, 0x00, 0x00];
320 assert_eq!(Classifier.classify(&data), Class::Media);
321 }
322
323 #[test]
324 fn elf_magic_detected() {
325 let data = [0x7F, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00];
326 assert_eq!(Classifier.classify(&data), Class::Code);
327 }
328
329 #[test]
330 fn macho_magic_detected() {
331 let data = [0xFE, 0xED, 0xFA, 0xCF, 0x00, 0x00, 0x00, 0x01];
332 assert_eq!(Classifier.classify(&data), Class::Code);
333 }
334
335 #[test]
336 fn pe_magic_detected() {
337 let data = [0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00];
338 assert_eq!(Classifier.classify(&data), Class::Code);
339 }
340
341 #[test]
342 fn plain_text_classifies_as_text() {
343 let text = b"Hello, world!\nThis is a plain text file.\nLines of prose.\n";
344 assert_eq!(Classifier.classify(text), Class::Text);
345 }
346
347 #[test]
348 fn code_like_source_classifies_as_text() {
349 let source = b"fn main() {\n println!(\"hello\");\n}\n";
350 assert_eq!(Classifier.classify(source), Class::Text);
351 }
352
353 #[test]
354 fn mostly_zeros_classifies_as_sparse() {
355 let mut data = vec![0u8; 4096];
356 data[0] = 0x42;
357 data[100] = 0x99;
358 assert_eq!(Classifier.classify(&data), Class::Sparse);
359 }
360
361 #[test]
362 fn high_entropy_random_classifies_as_compressed() {
363 let mut data = Vec::with_capacity(4096);
364 let mut state: u64 = 1;
365 for _ in 0..4096 {
366 state = state
367 .wrapping_mul(6_364_136_223_846_793_005)
368 .wrapping_add(1_442_695_040_888_963_407);
369 data.push(u8::try_from(state >> 56).expect("fits u8"));
370 }
371 let class = Classifier.classify(&data);
372 assert_eq!(
378 class,
379 Class::Incompressible,
380 "expected Incompressible for high-entropy random, got {class:?}"
381 );
382 }
383
384 #[test]
385 fn mid_entropy_non_printable_classifies_as_binary() {
386 let mut data = Vec::with_capacity(4096);
389 for i in 0..4096u32 {
390 data.push(u8::try_from((i * 7 + 0x80) & 0xFF).expect("fits"));
391 }
392 let class = Classifier.classify(&data);
393 assert!(
395 class != Class::Text && class != Class::Sparse,
396 "expected non-text non-sparse for mid-entropy non-printable, got {class:?}"
397 );
398 }
399
400 #[test]
401 fn class_to_id_round_trips() {
402 for class in [
403 Class::Text,
404 Class::Code,
405 Class::Binary,
406 Class::Compressed,
407 Class::Media,
408 Class::Sparse,
409 ] {
410 assert_eq!(class.as_str().len(), class.as_str().len());
411 assert_ne!(class.to_id(), 0);
412 }
413 }
414
415 #[test]
416 fn classifier_is_deterministic() {
417 let data: Vec<u8> = (0..1024u32)
418 .map(|i| u8::try_from(i & 0xFF).expect("fits"))
419 .collect();
420 let a = Classifier.classify(&data);
421 let b = Classifier.classify(&data);
422 assert_eq!(a, b);
423 }
424}