use anyhow::Result;
use clap::Subcommand;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::net::ToSocketAddrs;
use std::path::Path;
const VT_HOST: &str = "www.virustotal.com";
const VT_API_BASE: &str = "https://www.virustotal.com/api/v3/files";
const MAX_FILE_SIZE_BYTES: u64 = 500 * 1024 * 1024;
#[derive(Subcommand, Debug)]
pub enum FilescanAction {
Check {
path: String,
},
}
pub fn dispatch(action: FilescanAction) {
let result = match action {
FilescanAction::Check { path } => cmd_check(&path),
};
if let Err(e) = result {
eprintln!("[filescan] error: {e}");
std::process::exit(1);
}
}
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
}
std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified(),
}
}
fn check_host_not_private(host: &str) -> Result<()> {
let resolved: Vec<_> = format!("{host}:443")
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("DNS resolution failed for '{host}': {e}"))?
.collect();
for addr in &resolved {
if is_private_ip(addr.ip()) {
anyhow::bail!(
"egress blocked: '{host}' resolves to private/internal address {} — refusing to send hash lookup",
addr.ip()
);
}
}
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = std::fs::File::open(path)
.map_err(|e| anyhow::anyhow!("cannot open file '{}': {e}", path.display()))?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher)?;
Ok(hex_encode(&hasher.finalize()))
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(Debug, Deserialize)]
struct VtEnvelope {
data: VtData,
}
#[derive(Debug, Deserialize)]
struct VtData {
attributes: VtAttributes,
}
#[derive(Debug, Deserialize)]
struct VtAttributes {
last_analysis_stats: VtStats,
#[serde(default)]
last_analysis_results: BTreeMap<String, VtEngineResult>,
#[serde(default)]
meaningful_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct VtStats {
#[serde(default)]
malicious: u32,
#[serde(default)]
suspicious: u32,
#[serde(default)]
undetected: u32,
#[serde(default)]
harmless: u32,
#[serde(default)]
timeout: u32,
}
#[derive(Debug, Deserialize)]
struct VtEngineResult {
category: String,
#[serde(default)]
result: Option<String>,
}
fn cmd_check(path_str: &str) -> Result<()> {
let path = Path::new(path_str);
let meta = std::fs::metadata(path)
.map_err(|e| anyhow::anyhow!("cannot read '{}': {e}", path.display()))?;
if !meta.is_file() {
anyhow::bail!("not a regular file: {}", path.display());
}
if meta.len() > MAX_FILE_SIZE_BYTES {
anyhow::bail!(
"file too large ({} bytes, max {} bytes) — hash it yourself with `shasum -a 256` \
and look it up at https://www.virustotal.com/gui/home/search",
meta.len(),
MAX_FILE_SIZE_BYTES
);
}
let api_key = std::env::var("VT_API_KEY").map_err(|_| {
anyhow::anyhow!(
"VT_API_KEY not set. Get a free key at https://www.virustotal.com/gui/my-apikey \
then run: export VT_API_KEY=your_key_here"
)
})?;
eprintln!("[filescan] hashing {}...", path.display());
let hash = sha256_file(path)?;
println!("SHA-256: {hash}");
check_host_not_private(VT_HOST)?;
eprintln!("[filescan] querying VirusTotal...");
let url = format!("{VT_API_BASE}/{hash}");
let call = ureq::get(&url)
.header("x-apikey", &api_key)
.header("User-Agent", "yana-rt/1.3 filescan")
.call();
let mut resp = match call {
Ok(r) => r,
Err(ureq::Error::StatusCode(404)) => {
print_unknown(path);
return Ok(());
}
Err(ureq::Error::StatusCode(429)) => {
anyhow::bail!(
"VirusTotal rate limit hit (free tier: 4 requests/min, 500/day) — try again shortly"
);
}
Err(ureq::Error::StatusCode(401)) => {
anyhow::bail!("VirusTotal rejected VT_API_KEY (401) — check the key is correct");
}
Err(e) => anyhow::bail!("VirusTotal request failed: {e}"),
};
let body_text = resp
.body_mut()
.read_to_string()
.map_err(|e| anyhow::anyhow!("failed to read VirusTotal response body: {e}"))?;
let parsed: VtEnvelope = serde_json::from_str(&body_text)
.map_err(|e| anyhow::anyhow!("failed to parse VirusTotal response: {e}"))?;
print_result(path, &hash, &parsed);
Ok(())
}
fn print_unknown(path: &Path) {
println!();
println!("UNKNOWN — this file's hash has never been submitted to VirusTotal.");
println!("This does NOT mean it's safe — it means no one has scanned this exact file before.");
println!(
"Recommendation: do not open '{}' unless you trust the source.",
path.display()
);
println!("For certainty, submit it for a full scan: https://www.virustotal.com/gui/home/upload");
}
fn print_result(path: &Path, hash: &str, resp: &VtEnvelope) {
let stats = &resp.data.attributes.last_analysis_stats;
let flagged = stats.malicious + stats.suspicious;
let total = stats.malicious + stats.suspicious + stats.undetected + stats.harmless + stats.timeout;
println!();
if flagged > 0 {
println!("FLAGGED — do not open: {}", path.display());
println!(" {flagged}/{total} engines flagged this as malicious/suspicious:");
for (engine, result) in &resp.data.attributes.last_analysis_results {
if result.category == "malicious" || result.category == "suspicious" {
println!(
" - {engine}: {} ({})",
result.category,
result.result.as_deref().unwrap_or("?")
);
}
}
} else {
println!("CLEAN — {}/{total} engines checked, none flagged it.", stats.harmless + stats.undetected);
}
println!(" Hash: {hash}");
if let Some(name) = &resp.data.attributes.meaningful_name {
println!(" Known filename on VirusTotal: {name}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn sha256_matches_known_vector() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"abc").unwrap();
let hash = sha256_file(f.path()).unwrap();
assert_eq!(
hash,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn sha256_empty_file() {
let f = tempfile::NamedTempFile::new().unwrap();
let hash = sha256_file(f.path()).unwrap();
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn sha256_missing_file_errors() {
let result = sha256_file(Path::new("/nonexistent/path/does-not-exist"));
assert!(result.is_err());
}
#[test]
fn is_private_ip_detects_loopback_and_rfc1918() {
assert!(is_private_ip("127.0.0.1".parse().unwrap()));
assert!(is_private_ip("10.0.0.1".parse().unwrap()));
assert!(is_private_ip("192.168.1.1".parse().unwrap()));
assert!(is_private_ip("169.254.169.254".parse().unwrap())); assert!(!is_private_ip("8.8.8.8".parse().unwrap()));
}
#[test]
fn hex_encode_produces_lowercase_hex() {
assert_eq!(hex_encode(&[0xab, 0xcd, 0x01]), "abcd01");
}
}