use std::collections::HashSet;
use std::io::BufRead;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HashAlgorithm {
Md5,
Sha1,
Sha256,
}
impl HashAlgorithm {
#[must_use]
pub const fn hex_len(self) -> usize {
match self {
Self::Md5 => 32,
Self::Sha1 => 40,
Self::Sha256 => 64,
}
}
#[must_use]
pub fn from_hex_len(len: usize) -> Option<Self> {
match len {
32 => Some(Self::Md5),
40 => Some(Self::Sha1),
64 => Some(Self::Sha256),
_ => None,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Md5 => "MD5",
Self::Sha1 => "SHA1",
Self::Sha256 => "SHA256",
}
}
}
#[derive(Debug)]
pub struct FeedError {
pub hash: String,
pub expected: String,
}
impl std::fmt::Display for FeedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid hash {:?} (expected {})",
self.hash, self.expected
)
}
}
impl std::error::Error for FeedError {}
impl From<std::io::Error> for FeedError {
fn from(e: std::io::Error) -> Self {
Self {
hash: String::new(),
expected: format!("readable file: {e}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HashMatch {
pub hash: String,
pub algorithm: HashAlgorithm,
pub source: String,
}
#[derive(Debug)]
pub struct HashFeed {
bad_md5: HashSet<String>,
bad_sha1: HashSet<String>,
bad_sha256: HashSet<String>,
good_md5: HashSet<String>,
good_sha1: HashSet<String>,
good_sha256: HashSet<String>,
source_label: String,
}
impl HashFeed {
#[must_use]
pub fn new(source_label: impl Into<String>) -> Self {
Self {
bad_md5: HashSet::new(),
bad_sha1: HashSet::new(),
bad_sha256: HashSet::new(),
good_md5: HashSet::new(),
good_sha1: HashSet::new(),
good_sha256: HashSet::new(),
source_label: source_label.into(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.source_label
}
fn normalize(hash: &str) -> Result<(String, HashAlgorithm), FeedError> {
let normalized = hash.trim().to_lowercase();
let algo = HashAlgorithm::from_hex_len(normalized.len()).ok_or_else(|| FeedError {
hash: hash.to_string(),
expected: "MD5(32), SHA1(40), or SHA256(64) hex chars".to_string(),
})?;
if !normalized.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(FeedError {
hash: hash.to_string(),
expected: format!("{} hex", algo.name()),
});
}
Ok((normalized, algo))
}
pub fn insert_bad(&mut self, hash: &str) -> Result<(), FeedError> {
let (normalized, algo) = Self::normalize(hash)?;
match algo {
HashAlgorithm::Md5 => self.bad_md5.insert(normalized),
HashAlgorithm::Sha1 => self.bad_sha1.insert(normalized),
HashAlgorithm::Sha256 => self.bad_sha256.insert(normalized),
};
Ok(())
}
pub fn insert_good(&mut self, hash: &str) -> Result<(), FeedError> {
let (normalized, algo) = Self::normalize(hash)?;
match algo {
HashAlgorithm::Md5 => self.good_md5.insert(normalized),
HashAlgorithm::Sha1 => self.good_sha1.insert(normalized),
HashAlgorithm::Sha256 => self.good_sha256.insert(normalized),
};
Ok(())
}
#[must_use]
pub fn lookup_bad(&self, hash: &str) -> Option<HashMatch> {
let normalized = hash.trim().to_lowercase();
let algo = HashAlgorithm::from_hex_len(normalized.len())?;
let found = match algo {
HashAlgorithm::Md5 => self.bad_md5.contains(&normalized),
HashAlgorithm::Sha1 => self.bad_sha1.contains(&normalized),
HashAlgorithm::Sha256 => self.bad_sha256.contains(&normalized),
};
found.then(|| HashMatch {
hash: normalized,
algorithm: algo,
source: self.source_label.clone(),
})
}
#[must_use]
pub fn is_known_good(&self, hash: &str) -> bool {
let normalized = hash.trim().to_lowercase();
let Some(algo) = HashAlgorithm::from_hex_len(normalized.len()) else {
return false;
};
match algo {
HashAlgorithm::Md5 => self.good_md5.contains(&normalized),
HashAlgorithm::Sha1 => self.good_sha1.contains(&normalized),
HashAlgorithm::Sha256 => self.good_sha256.contains(&normalized),
}
}
pub fn load_bad_from_file(&mut self, path: &Path) -> Result<usize, FeedError> {
self.load_from_file(path, true)
}
pub fn load_good_from_file(&mut self, path: &Path) -> Result<usize, FeedError> {
self.load_from_file(path, false)
}
fn load_from_file(&mut self, path: &Path, bad: bool) -> Result<usize, FeedError> {
let reader = std::io::BufReader::new(std::fs::File::open(path)?);
let mut count = 0;
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let hash = trimmed.split([',', '\t', ' ']).next().unwrap_or(trimmed);
let inserted = if bad {
self.insert_bad(hash)
} else {
self.insert_good(hash)
};
if inserted.is_ok() {
count += 1;
}
}
Ok(count)
}
#[must_use]
pub fn bad_count(&self) -> usize {
self.bad_md5.len() + self.bad_sha1.len() + self.bad_sha256.len()
}
#[must_use]
pub fn good_count(&self) -> usize {
self.good_md5.len() + self.good_sha1.len() + self.good_sha256.len()
}
}
#[cfg(test)]
mod tests {
use super::{HashAlgorithm, HashFeed};
use std::io::Write;
#[test]
fn algorithm_detects_by_hex_length() {
assert_eq!(HashAlgorithm::from_hex_len(32), Some(HashAlgorithm::Md5));
assert_eq!(HashAlgorithm::from_hex_len(40), Some(HashAlgorithm::Sha1));
assert_eq!(HashAlgorithm::from_hex_len(64), Some(HashAlgorithm::Sha256));
assert_eq!(HashAlgorithm::from_hex_len(10), None);
assert_eq!(HashAlgorithm::Md5.hex_len(), 32);
assert_eq!(HashAlgorithm::Sha256.name(), "SHA256");
}
#[test]
fn insert_and_lookup_bad_across_algorithms() {
let mut feed = HashFeed::new("test-feed");
assert_eq!(feed.name(), "test-feed");
let md5 = "a".repeat(32);
let sha1 = "b".repeat(40);
let sha256 = "c".repeat(64);
feed.insert_bad(&md5).unwrap();
feed.insert_bad(&sha1).unwrap();
feed.insert_bad(&sha256).unwrap();
assert_eq!(feed.bad_count(), 3);
let m = feed.lookup_bad(&sha256).unwrap();
assert_eq!(m.algorithm, HashAlgorithm::Sha256);
assert_eq!(m.source, "test-feed");
assert_eq!(m.hash, sha256);
assert!(feed.lookup_bad(&"d".repeat(64)).is_none());
}
#[test]
fn lookup_is_case_and_whitespace_insensitive() {
let mut feed = HashFeed::new("f");
let sha256 = "AB".repeat(32); feed.insert_bad(&sha256).unwrap();
assert!(feed
.lookup_bad(&format!(" {} ", sha256.to_lowercase()))
.is_some());
}
#[test]
fn known_good_filtering() {
let mut feed = HashFeed::new("nsrl-subset");
let good = "1".repeat(64);
feed.insert_good(&good).unwrap();
assert!(feed.is_known_good(&good));
assert!(feed.is_known_good(&good.to_uppercase()));
assert!(!feed.is_known_good(&"2".repeat(64)));
assert!(!feed.is_known_good("not-a-hash"));
assert_eq!(feed.good_count(), 1);
}
#[test]
fn invalid_hash_is_rejected_loudly() {
let mut feed = HashFeed::new("f");
assert!(feed.insert_bad("xyz").is_err()); assert!(feed.insert_bad(&"z".repeat(64)).is_err()); assert_eq!(feed.bad_count(), 0);
}
#[test]
fn loads_bad_and_good_from_file_skipping_comments() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "# a comment").unwrap();
writeln!(f).unwrap();
writeln!(f, "{}", "a".repeat(64)).unwrap();
writeln!(f, "{},extra,columns", "b".repeat(32)).unwrap(); writeln!(f, "not-a-valid-hash").unwrap(); f.flush().unwrap();
let mut feed = HashFeed::new("file-feed");
let n = feed.load_bad_from_file(f.path()).unwrap();
assert_eq!(n, 2, "two valid hashes; comment/blank/invalid skipped");
assert!(feed.lookup_bad(&"a".repeat(64)).is_some());
assert!(feed.lookup_bad(&"b".repeat(32)).is_some());
let mut g = tempfile::NamedTempFile::new().unwrap();
writeln!(g, "{}", "e".repeat(64)).unwrap();
g.flush().unwrap();
let mut good = HashFeed::new("good-file");
assert_eq!(good.load_good_from_file(g.path()).unwrap(), 1);
assert!(good.is_known_good(&"e".repeat(64)));
}
}