use anyhow::{Context, Result};
use content_inspector::{ContentType, inspect};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
pub struct BinaryDetector {
max_sample_size: usize,
binary_threshold: f64,
}
impl Default for BinaryDetector {
fn default() -> Self {
Self {
max_sample_size: 8192, binary_threshold: 0.3, }
}
}
impl BinaryDetector {
pub fn new(max_sample_size: usize, binary_threshold: f64) -> Self {
Self {
max_sample_size,
binary_threshold,
}
}
pub fn is_binary<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
let path = path.as_ref();
if self.is_binary_by_extension(path) {
return Ok(true);
}
if let Ok(is_binary_by_signature) = self.check_binary_signature_only(path) {
if is_binary_by_signature {
return Ok(true);
}
}
if let Ok(content_type) = self.detect_by_content_inspector(path) {
match content_type {
ContentType::BINARY => return Ok(true),
ContentType::UTF_8 | ContentType::UTF_8_BOM |
ContentType::UTF_16LE | ContentType::UTF_16BE |
ContentType::UTF_32LE | ContentType::UTF_32BE => return Ok(false),
}
}
self.is_binary_by_content_analysis(path)
}
fn is_binary_by_extension(&self, path: &Path) -> bool {
if let Some(extension) = path.extension() {
if let Some(ext_str) = extension.to_str() {
let ext_lower = ext_str.to_lowercase();
return BINARY_EXTENSIONS.contains(&ext_lower.as_str());
}
}
false
}
fn check_binary_signature_only(&self, path: &Path) -> Result<bool> {
let file = File::open(path)
.with_context(|| format!("Failed to open file for signature check: {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut buffer = vec![0; 32];
let bytes_read = reader.read(&mut buffer)
.with_context(|| format!("Failed to read file for signature check: {}", path.display()))?;
if bytes_read == 0 {
return Ok(false); }
buffer.truncate(bytes_read);
Ok(self.has_binary_signature(&buffer))
}
fn detect_by_content_inspector(&self, path: &Path) -> Result<ContentType> {
let mut file = File::open(path)
.with_context(|| format!("Failed to open file for binary detection: {}", path.display()))?;
let mut buffer = vec![0; self.max_sample_size];
let bytes_read = file.read(&mut buffer)
.with_context(|| format!("Failed to read file for binary detection: {}", path.display()))?;
buffer.truncate(bytes_read);
Ok(inspect(&buffer))
}
fn is_binary_by_content_analysis(&self, path: &Path) -> Result<bool> {
let file = File::open(path)
.with_context(|| format!("Failed to open file for content analysis: {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut buffer = vec![0; self.max_sample_size];
let bytes_read = reader.read(&mut buffer)
.with_context(|| format!("Failed to read file for content analysis: {}", path.display()))?;
if bytes_read == 0 {
return Ok(false); }
buffer.truncate(bytes_read);
if self.has_binary_signature(&buffer) {
return Ok(true);
}
if buffer.contains(&0) {
return Ok(true);
}
if let Ok(text) = std::str::from_utf8(&buffer) {
let non_printable_count = text.chars()
.filter(|&c| c.is_control() && !matches!(c, '\t' | '\n' | '\r'))
.count();
let non_printable_ratio = non_printable_count as f64 / text.chars().count() as f64;
return Ok(non_printable_ratio > self.binary_threshold);
}
let non_printable_count = buffer.iter()
.filter(|&&byte| !is_printable_ascii(byte) && !is_valid_utf8_start(byte))
.count();
let non_printable_ratio = non_printable_count as f64 / bytes_read as f64;
Ok(non_printable_ratio > self.binary_threshold)
}
fn has_binary_signature(&self, buffer: &[u8]) -> bool {
if buffer.len() < 2 {
return false;
}
if self.is_protocol_buffer(buffer) {
return true;
}
match &buffer[0..2] {
[0x78, 0x01] | [0x78, 0x9c] | [0x78, 0xda] => true,
[0x89, 0x50] if buffer.len() >= 8 && &buffer[0..8] == b"\x89PNG\r\n\x1a\n" => true,
[0xff, 0xd8] => true,
[0x25, 0x50] if buffer.len() >= 4 && &buffer[0..4] == b"%PDF" => true,
[0x50, 0x4b] => true,
[0x7f, 0x45] if buffer.len() >= 4 && &buffer[0..4] == b"\x7fELF" => true,
[0x4d, 0x5a] => true,
[0xfe, 0xed] | [0xfe, 0xec] | [0xce, 0xfa] | [0xcf, 0xfa] => true,
_ if buffer.len() >= 262 && &buffer[257..262] == b"ustar" => true,
[0x1f, 0x8b] => true,
[0x42, 0x5a] if buffer.len() >= 3 && buffer[2] == 0x68 => true,
[0xfd, 0x37] if buffer.len() >= 6 && &buffer[0..6] == b"\xfd7zXZ\x00" => true,
[0x37, 0x7a] if buffer.len() >= 6 && &buffer[0..6] == b"7z\xbc\xaf\x27\x1c" => true,
[0xca, 0xfe] => true,
[0x64, 0x65] if buffer.len() >= 8 && &buffer[0..8] == b"dex\n035\0" => true,
_ => false,
}
}
fn is_protocol_buffer(&self, buffer: &[u8]) -> bool {
if buffer.len() < 8 {
return false;
}
let ascii_count = buffer.iter().take(64).filter(|&&b| b >= 0x20 && b <= 0x7E).count();
let ascii_ratio = ascii_count as f64 / buffer.len().min(64) as f64;
let is_valid_utf8 = std::str::from_utf8(buffer).is_ok();
if ascii_ratio > 0.7 || is_valid_utf8 {
return false; }
let mut valid_proto_sequences = 0;
let mut i = 0;
while i < buffer.len().min(32) { let byte = buffer[i];
let wire_type = byte & 0x07;
let field_number = byte >> 3;
if matches!(wire_type, 0 | 1 | 2 | 5) && field_number > 0 && field_number < 32 {
match wire_type {
0 => {
if i + 1 < buffer.len() {
let next = buffer[i + 1];
if next < 0x80 || (next & 0x80 != 0 && next & 0x7F < 0x40) {
valid_proto_sequences += 1;
i += 2; continue;
}
}
},
2 => {
if i + 1 < buffer.len() {
let length = buffer[i + 1];
if length > 0 && length < 64 && (i + 2 + length as usize) <= buffer.len() {
valid_proto_sequences += 2; i += 2 + length as usize; continue;
}
}
},
1 | 5 => {
valid_proto_sequences += 1;
i += if wire_type == 1 { 9 } else { 5 }; continue;
},
_ => {}
}
}
i += 1;
}
let has_null_bytes = buffer[..buffer.len().min(64)].contains(&0);
let non_ascii_count = buffer.iter().take(64).filter(|&&b| b > 0x7E || (b < 0x20 && !matches!(b, 0x09 | 0x0A | 0x0D))).count();
let non_ascii_ratio = non_ascii_count as f64 / buffer.len().min(64) as f64;
valid_proto_sequences >= 2 && (has_null_bytes || non_ascii_ratio > 0.2)
}
pub fn is_text_file<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
Ok(!self.is_binary(path)?)
}
pub fn get_binary_reason<P: AsRef<Path>>(&self, path: P) -> Result<Option<String>> {
let path = path.as_ref();
if self.is_binary_by_extension(path) {
return Ok(Some("Binary file extension".to_string()));
}
if let Ok(content_type) = self.detect_by_content_inspector(path) {
match content_type {
ContentType::BINARY => return Ok(Some("Content inspection detected binary".to_string())),
_ => {}
}
}
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut buffer = vec![0; self.max_sample_size];
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
return Ok(None);
}
buffer.truncate(bytes_read);
if self.has_binary_signature(&buffer) {
if self.is_protocol_buffer(&buffer) {
return Ok(Some("Detected as Protocol Buffer binary data".to_string()));
}
let signature_desc = match &buffer[0..2.min(buffer.len())] {
[0x78, 0x01] | [0x78, 0x9c] | [0x78, 0xda] => "zlib compressed data",
[0x89, 0x50] => "PNG image",
[0xff, 0xd8] => "JPEG image",
[0x25, 0x50] => "PDF document",
[0x50, 0x4b] => "ZIP archive",
[0x7f, 0x45] => "ELF executable",
[0x4d, 0x5a] => "Windows executable",
[0xfe, 0xed] | [0xfe, 0xec] | [0xce, 0xfa] | [0xcf, 0xfa] => "Mach-O executable",
[0x1f, 0x8b] => "GZIP compressed",
[0x42, 0x5a] => "BZIP2 compressed",
_ => "binary file signature",
};
return Ok(Some(format!("Detected as {}", signature_desc)));
}
if buffer.contains(&0) {
return Ok(Some("Contains null bytes".to_string()));
}
let non_printable_count = buffer.iter()
.filter(|&&byte| !is_printable_ascii(byte) && !is_valid_utf8_start(byte))
.count();
let non_printable_ratio = non_printable_count as f64 / bytes_read as f64;
if non_printable_ratio > self.binary_threshold {
return Ok(Some(format!("High ratio of non-printable characters: {:.1}%", non_printable_ratio * 100.0)));
}
Ok(None)
}
}
fn is_printable_ascii(byte: u8) -> bool {
matches!(byte, 0x20..=0x7E | 0x09 | 0x0A | 0x0D) }
fn is_valid_utf8_start(byte: u8) -> bool {
byte < 0x80 || (byte >= 0xC0 && byte < 0xF8)
}
const BINARY_EXTENSIONS: &[&str] = &[
"exe", "dll", "so", "dylib", "app", "deb", "rpm", "msi", "dmg", "run", "bin",
"com", "bat", "cmd", "scr", "sys", "drv", "cpl", "ocx", "axl", "elf",
"zip", "tar", "gz", "bz2", "xz", "7z", "rar", "cab", "ace", "arc", "arj",
"lzh", "lha", "zoo", "cpio", "shar", "lz", "lzma", "tgz", "tbz", "tbz2",
"txz", "tlz", "Z", "deb", "rpm", "pkg", "apk", "snap", "flatpak", "appimage",
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "svg", "ico", "cur",
"psd", "ai", "eps", "raw", "cr2", "nef", "orf", "sr2", "dng", "exr", "hdr",
"tga", "pcx", "ppm", "pgm", "pbm", "xbm", "xpm", "jp2", "jpx", "j2k", "heic",
"heif", "avif", "jxl", "wbmp", "emf", "wmf", "cgm", "pic", "pict",
"mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "3gp", "3g2",
"mpg", "mpeg", "asf", "rm", "rmvb", "vob", "mts", "m2ts", "f4v",
"ogv", "divx", "xvid", "mxf", "amv", "drc", "gif", "gifv", "mng", "qt",
"yuv", "roq", "svi", "viv", "vp8", "vp9", "av1",
"mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "aiff", "au", "ra",
"opus", "ape", "ac3", "dts", "amr", "awb", "gsm", "m4r", "mpc", "tta",
"wv", "8svx", "cda", "mid", "midi", "rmi", "kar", "mod", "s3m", "xm",
"it", "669", "amf", "ams", "dbm", "dmf", "dsm", "far", "mdl", "med",
"mtm", "okt", "ptm", "stm", "ult", "umx", "mt2", "psm",
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
"rtf", "pages", "numbers", "key", "pub", "vsd", "vsdx", "mpp", "one",
"wps", "wpd", "sxw", "sxc", "sxi", "sxd", "stw", "stc", "sti", "std",
"odf", "otg", "otp", "ots", "ott", "odm", "oth", "odb", "oxt",
"db", "sqlite", "sqlite3", "mdb", "accdb", "dbf", "fdb", "gdb", "nsf",
"odb", "pdb", "wdb", "bak", "dmp", "frm", "myd", "myi", "ibd",
"fpt", "cdx", "idx", "ndx", "mdx", "fox", "prg", "fxp", "dbc", "dct",
"o", "obj", "lib", "a", "pdb", "ilk", "exp", "map", "res", "rc", "tlb",
"manifest", "cache", "tmp", "temp", "bak", "swp", "swo", "orig", "rej",
"class", "jar", "war", "ear", "jmod", "jimage", "hprof", "heap", "jfr",
"pdb", "mdb", "nupkg", "snupkg", "vsix", "msi", "msm", "msp", "mst",
"wixlib", "wixobj", "wixpdb", "cab", "msu", "exe", "dll", "winmd",
"avro", "parquet", "orc", "arrow", "cbor",
"msgpack", "bson", "pickle", "pkl", "p", "joblib", "npy", "npz",
"iso", "img", "vdi", "vmdk", "qcow2", "vhd", "vhdx", "hdd", "parallels",
"pvm", "ova", "ovf", "box", "vagrant", "docker", "tar", "tgz",
"pak", "wad", "vpk", "gcf", "ncf", "xnb", "unity3d", "unitypackage",
"fbx", "dae", "obj", "3ds", "max", "blend", "ma", "mb", "c4d", "lwo",
"lws", "mesh", "x3d", "ply", "stl", "off", "ase", "mdl", "md2", "md3",
"dwg", "dxf", "step", "stp", "iges", "igs", "catpart", "catproduct",
"prt", "asm", "par", "psm", "x_t", "x_b", "sat", "3dm", "rvt", "rfa",
"ifc", "skp", "kmz", "kml",
"hdf5", "h5", "nc", "cdf", "fits", "fts", "mat", "sav", "dta", "por",
"sas7bdat", "xpt", "rda", "rds", "feather", "arrow", "zarr", "blosc",
"ttf", "otf", "woff", "woff2", "eot", "fon", "fnt", "pfb", "pfm", "afm",
"bdf", "pcf", "snf", "gsf", "t1", "t42", "cff", "cid", "dfont", "suit",
"dat", "blk", "rev", "wallet", "keystore", "p12", "pfx", "jks", "bks",
"apk", "ipa", "xap", "appx", "msix", "aab", "dex", "odex", "vdex",
"art", "oat", "hex", "bin", "elf", "axf", "out", "uf2", "hex",
"bin", "dat", "dump", "raw", "rom", "firmware", "bios", "efi", "uefi",
"swap", "hiberfil", "pagefile", "core", "crashdump", "mdmp", "dmp",
"lock", "pid", "sock", "fifo", "pipe", "device", "special",
];
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_binary_extension_detection() {
let detector = BinaryDetector::default();
assert!(detector.is_binary_by_extension(Path::new("test.exe")));
assert!(detector.is_binary_by_extension(Path::new("test.jpg")));
assert!(detector.is_binary_by_extension(Path::new("test.pdf")));
assert!(detector.is_binary_by_extension(Path::new("TEST.EXE")));
assert!(!detector.is_binary_by_extension(Path::new("test.txt")));
assert!(!detector.is_binary_by_extension(Path::new("test.rs")));
assert!(!detector.is_binary_by_extension(Path::new("test.py")));
assert!(!detector.is_binary_by_extension(Path::new("Makefile")));
}
#[test]
fn test_text_file_detection() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let text_file = temp_dir.path().join("test.txt");
let mut file = File::create(&text_file)?;
writeln!(file, "This is a text file with some content.")?;
writeln!(file, "It has multiple lines and should be detected as text.")?;
assert!(detector.is_text_file(&text_file)?);
assert!(!detector.is_binary(&text_file)?);
Ok(())
}
#[test]
fn test_binary_file_detection() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let binary_file = temp_dir.path().join("test.bin");
let mut file = File::create(&binary_file)?;
file.write_all(&[0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD])?;
assert!(!detector.is_text_file(&binary_file)?);
assert!(detector.is_binary(&binary_file)?);
Ok(())
}
#[test]
fn test_empty_file_detection() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let empty_file = temp_dir.path().join("empty.txt");
File::create(&empty_file)?;
assert!(detector.is_text_file(&empty_file)?);
assert!(!detector.is_binary(&empty_file)?);
Ok(())
}
#[test]
fn test_utf8_file_detection() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let utf8_file = temp_dir.path().join("unicode.txt");
let mut file = File::create(&utf8_file)?;
writeln!(file, "Hello, 世界! 🌍")?;
writeln!(file, "This file contains émojis and unicode characters: 日本語")?;
drop(file);
assert!(detector.is_text_file(&utf8_file)?);
assert!(!detector.is_binary(&utf8_file)?);
Ok(())
}
#[test]
fn test_binary_reason() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let exe_file = temp_dir.path().join("test.exe");
File::create(&exe_file)?;
let reason = detector.get_binary_reason(&exe_file)?;
assert!(reason.is_some());
assert!(reason.unwrap().contains("extension"));
let binary_file = temp_dir.path().join("test.bin"); let mut file = File::create(&binary_file)?;
file.write_all(b"some text\x00more text")?;
drop(file); let reason = detector.get_binary_reason(&binary_file)?;
assert!(reason.is_some());
let reason_str = reason.unwrap();
assert!(reason_str.contains("extension") || reason_str.contains("null") || reason_str.contains("binary"));
let text_file = temp_dir.path().join("test.txt");
let mut file = File::create(&text_file)?;
writeln!(file, "This is just text")?;
let reason = detector.get_binary_reason(&text_file)?;
assert!(reason.is_none());
Ok(())
}
#[test]
fn test_printable_ascii() {
assert!(is_printable_ascii(b' ')); assert!(is_printable_ascii(b'A')); assert!(is_printable_ascii(b'0')); assert!(is_printable_ascii(b'~')); assert!(is_printable_ascii(b'\t')); assert!(is_printable_ascii(b'\n')); assert!(is_printable_ascii(b'\r'));
assert!(!is_printable_ascii(0x00)); assert!(!is_printable_ascii(0x01)); assert!(!is_printable_ascii(0x7F)); assert!(!is_printable_ascii(0x80)); assert!(!is_printable_ascii(0xFF)); }
#[test]
fn test_utf8_start_detection() {
assert!(is_valid_utf8_start(0x41)); assert!(is_valid_utf8_start(0xC2)); assert!(is_valid_utf8_start(0xE0)); assert!(is_valid_utf8_start(0xF0));
assert!(!is_valid_utf8_start(0x80)); assert!(!is_valid_utf8_start(0xBF)); assert!(!is_valid_utf8_start(0xF8)); assert!(!is_valid_utf8_start(0xFF)); }
#[test]
fn test_protocol_buffer_detection() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let protobuf_data = vec![
0x08, 0x96, 0x01, 0x12, 0x04, 0x74, 0x65, 0x73, 0x74, 0x1a, 0x03, 0x66, 0x6f, 0x6f, 0x20, 0x42, ];
let protobuf_file = temp_dir.path().join("test.pb");
let mut file = File::create(&protobuf_file)?;
file.write_all(&protobuf_data)?;
drop(file);
assert!(detector.is_binary(&protobuf_file)?);
assert!(!detector.is_text_file(&protobuf_file)?);
let reason = detector.get_binary_reason(&protobuf_file)?;
assert!(reason.is_some());
let reason_str = reason.unwrap();
assert!(reason_str.contains("Protocol Buffer") || reason_str.contains("binary") || reason_str.contains("extension"));
Ok(())
}
#[test]
fn test_protocol_buffer_patterns() {
let detector = BinaryDetector::default();
let protobuf_patterns = vec![
0x08, 0x96, 0x01,
0x12, 0x04, 0x74, 0x65, 0x73, 0x74,
0x20, 0x42,
];
assert!(detector.is_protocol_buffer(&protobuf_patterns));
let text_data = b"This is plain text content";
assert!(!detector.is_protocol_buffer(text_data));
assert!(!detector.is_protocol_buffer(&[]));
assert!(!detector.is_protocol_buffer(&[0x01]));
}
#[test]
fn test_protobuf_vs_text_distinction() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let pseudo_protobuf_text = temp_dir.path().join("pseudo.txt");
let mut file = File::create(&pseudo_protobuf_text)?;
writeln!(file, "This file contains some bytes like \\x08 and \\x12 but is text")?;
drop(file);
assert!(detector.is_text_file(&pseudo_protobuf_text)?);
assert!(!detector.is_binary(&pseudo_protobuf_text)?);
let real_protobuf = temp_dir.path().join("real.pb");
let protobuf_data = vec![
0x08, 0x96, 0x01, 0x12, 0x04, 0x74, 0x65, 0x73, 0x74, 0x1a, 0x06, 0x62, 0x69, 0x6e, 0x61, 0x72, 0x79, 0x20, 0x2a, 0x2a, 0x08, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, ];
let mut file = File::create(&real_protobuf)?;
file.write_all(&protobuf_data)?;
drop(file);
assert!(detector.is_binary(&real_protobuf)?);
assert!(!detector.is_text_file(&real_protobuf)?);
Ok(())
}
#[test]
fn test_pb_file_detection() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let pb_file = temp_dir.path().join("test.pb");
let mut file = File::create(&pb_file)?;
file.write_all(b"some protobuf binary data")?;
drop(file);
assert!(detector.is_binary(&pb_file)?, ".pb file should be detected as binary");
assert!(!detector.is_text_file(&pb_file)?, ".pb file should NOT be detected as text");
let reason = detector.get_binary_reason(&pb_file)?;
assert!(reason.is_some(), ".pb file should have a binary reason");
assert!(reason.unwrap().contains("extension"), "Reason should mention extension");
Ok(())
}
#[test]
fn test_discovery_phase_simulation() -> Result<()> {
let temp_dir = TempDir::new()?;
let detector = BinaryDetector::default();
let pb_file = temp_dir.path().join("test.pb");
let txt_file = temp_dir.path().join("test.txt");
let rs_file = temp_dir.path().join("test.rs");
let mut file = File::create(&pb_file)?;
file.write_all(b"oldstring should not be processed in binary files")?;
drop(file);
let mut file = File::create(&txt_file)?;
file.write_all(b"This contains oldstring and should be processed")?;
drop(file);
let mut file = File::create(&rs_file)?;
file.write_all(b"fn main() { println!(\"oldstring\"); }")?;
drop(file);
let search_string = "oldstring";
let pb_is_text = detector.is_text_file(&pb_file)?;
println!("pb_file is_text_file: {}", pb_is_text);
if pb_is_text {
let contains_string = std::fs::read_to_string(&pb_file)?.contains(search_string);
println!("ERROR: .pb file classified as text and contains search string: {}", contains_string);
assert!(false, ".pb file should not be classified as text!");
} else {
println!("OK: .pb file correctly classified as binary, will not be processed for content");
}
assert!(detector.is_text_file(&txt_file)?, "txt file should be text");
assert!(detector.is_text_file(&rs_file)?, "rs file should be text");
Ok(())
}
}