use std::{fs, path::Path, sync::OnceLock};
use yara_x::Rules;
use crate::error::{Result, VoidCrawlError};
pub const DEFAULT_MAX_BYTES: u64 = 100 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Clean,
Flagged { reason: String },
}
impl Verdict {
pub fn is_clean(&self) -> bool {
matches!(self, Self::Clean)
}
}
#[derive(Debug, Clone)]
pub struct ScanReport {
pub verdict: Verdict,
pub detected_mime: Option<String>,
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct ScanConfig {
pub max_bytes: u64,
pub claimed_mime: Option<String>,
}
impl Default for ScanConfig {
fn default() -> Self {
Self { max_bytes: DEFAULT_MAX_BYTES, claimed_mime: None }
}
}
const RULES_SRC: &str = r#"
rule EICAR_Test_File {
meta:
description = "EICAR standard antivirus test file"
strings:
$a = "EICAR-STANDARD-ANTIVIRUS-TEST-FILE"
$b = "$H+H*"
condition:
all of them
}
"#;
fn rules() -> &'static Rules {
static RULES: OnceLock<Rules> = OnceLock::new();
RULES.get_or_init(|| {
let mut compiler = yara_x::Compiler::new();
match compiler.add_source(RULES_SRC) {
Ok(_) => compiler.build(),
Err(_) => yara_x::Compiler::new().build(),
}
})
}
pub fn scan_path(path: &Path, cfg: &ScanConfig) -> Result<ScanReport> {
let data = fs::read(path)
.map_err(|e| VoidCrawlError::Other(format!("read {}: {e}", path.display())))?;
Ok(scan_bytes(&data, cfg))
}
pub fn scan_bytes(data: &[u8], cfg: &ScanConfig) -> ScanReport {
let size = u64::try_from(data.len()).unwrap_or(u64::MAX);
let detected = infer::get(data);
let detected_mime = detected.map(|k| k.mime_type().to_string());
let flag = |reason: String| ScanReport {
verdict: Verdict::Flagged { reason },
detected_mime: detected_mime.clone(),
size,
};
if size > cfg.max_bytes {
return flag(format!("size {size} exceeds limit {}", cfg.max_bytes));
}
if let (Some(claimed), Some(kind)) = (cfg.claimed_mime.as_deref(), detected) {
if is_executable(kind) && !mime_is_executable(claimed) {
return flag(format!(
"content-type mismatch: claimed {claimed} but bytes are {} (.{})",
kind.mime_type(),
kind.extension()
));
}
}
let mut scanner = yara_x::Scanner::new(rules());
match scanner.scan(data) {
Ok(results) => {
if let Some(rule) = results.matching_rules().next() {
return flag(format!("matched signature: {}", rule.identifier()));
}
}
Err(e) => return flag(format!("scan error: {e}")),
}
ScanReport { verdict: Verdict::Clean, detected_mime, size }
}
fn is_executable(kind: infer::Type) -> bool {
matches!(kind.matcher_type(), infer::MatcherType::App)
}
fn mime_is_executable(mime: &str) -> bool {
mime.contains("executable")
|| matches!(
mime,
"application/x-msdownload"
| "application/vnd.microsoft.portable-executable"
| "application/x-mach-binary"
| "application/x-dosexec"
| "application/octet-stream"
)
}