1use std::collections::HashSet;
13use std::io::BufRead;
14use std::path::Path;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum HashAlgorithm {
19 Md5,
20 Sha1,
21 Sha256,
22}
23
24impl HashAlgorithm {
25 #[must_use]
27 pub const fn hex_len(self) -> usize {
28 match self {
29 Self::Md5 => 32,
30 Self::Sha1 => 40,
31 Self::Sha256 => 64,
32 }
33 }
34
35 #[must_use]
37 pub fn from_hex_len(len: usize) -> Option<Self> {
38 match len {
39 32 => Some(Self::Md5),
40 40 => Some(Self::Sha1),
41 64 => Some(Self::Sha256),
42 _ => None,
43 }
44 }
45
46 #[must_use]
48 pub const fn name(self) -> &'static str {
49 match self {
50 Self::Md5 => "MD5",
51 Self::Sha1 => "SHA1",
52 Self::Sha256 => "SHA256",
53 }
54 }
55}
56
57#[derive(Debug)]
59pub struct FeedError {
60 pub hash: String,
61 pub expected: String,
62}
63
64impl std::fmt::Display for FeedError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(
67 f,
68 "invalid hash {:?} (expected {})",
69 self.hash, self.expected
70 )
71 }
72}
73
74impl std::error::Error for FeedError {}
75
76impl From<std::io::Error> for FeedError {
77 fn from(e: std::io::Error) -> Self {
78 Self {
79 hash: String::new(),
80 expected: format!("readable file: {e}"),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct HashMatch {
88 pub hash: String,
89 pub algorithm: HashAlgorithm,
90 pub source: String,
91}
92
93#[derive(Debug)]
98pub struct HashFeed {
99 bad_md5: HashSet<String>,
100 bad_sha1: HashSet<String>,
101 bad_sha256: HashSet<String>,
102 good_md5: HashSet<String>,
103 good_sha1: HashSet<String>,
104 good_sha256: HashSet<String>,
105 source_label: String,
106}
107
108impl HashFeed {
109 #[must_use]
111 pub fn new(source_label: impl Into<String>) -> Self {
112 Self {
113 bad_md5: HashSet::new(),
114 bad_sha1: HashSet::new(),
115 bad_sha256: HashSet::new(),
116 good_md5: HashSet::new(),
117 good_sha1: HashSet::new(),
118 good_sha256: HashSet::new(),
119 source_label: source_label.into(),
120 }
121 }
122
123 #[must_use]
125 pub fn name(&self) -> &str {
126 &self.source_label
127 }
128
129 fn normalize(hash: &str) -> Result<(String, HashAlgorithm), FeedError> {
131 let normalized = hash.trim().to_lowercase();
132 let algo = HashAlgorithm::from_hex_len(normalized.len()).ok_or_else(|| FeedError {
133 hash: hash.to_string(),
134 expected: "MD5(32), SHA1(40), or SHA256(64) hex chars".to_string(),
135 })?;
136 if !normalized.bytes().all(|b| b.is_ascii_hexdigit()) {
137 return Err(FeedError {
138 hash: hash.to_string(),
139 expected: format!("{} hex", algo.name()),
140 });
141 }
142 Ok((normalized, algo))
143 }
144
145 pub fn insert_bad(&mut self, hash: &str) -> Result<(), FeedError> {
147 let (normalized, algo) = Self::normalize(hash)?;
148 match algo {
149 HashAlgorithm::Md5 => self.bad_md5.insert(normalized),
150 HashAlgorithm::Sha1 => self.bad_sha1.insert(normalized),
151 HashAlgorithm::Sha256 => self.bad_sha256.insert(normalized),
152 };
153 Ok(())
154 }
155
156 pub fn insert_good(&mut self, hash: &str) -> Result<(), FeedError> {
158 let (normalized, algo) = Self::normalize(hash)?;
159 match algo {
160 HashAlgorithm::Md5 => self.good_md5.insert(normalized),
161 HashAlgorithm::Sha1 => self.good_sha1.insert(normalized),
162 HashAlgorithm::Sha256 => self.good_sha256.insert(normalized),
163 };
164 Ok(())
165 }
166
167 #[must_use]
169 pub fn lookup_bad(&self, hash: &str) -> Option<HashMatch> {
170 let normalized = hash.trim().to_lowercase();
171 let algo = HashAlgorithm::from_hex_len(normalized.len())?;
172 let found = match algo {
173 HashAlgorithm::Md5 => self.bad_md5.contains(&normalized),
174 HashAlgorithm::Sha1 => self.bad_sha1.contains(&normalized),
175 HashAlgorithm::Sha256 => self.bad_sha256.contains(&normalized),
176 };
177 found.then(|| HashMatch {
178 hash: normalized,
179 algorithm: algo,
180 source: self.source_label.clone(),
181 })
182 }
183
184 #[must_use]
186 pub fn is_known_good(&self, hash: &str) -> bool {
187 let normalized = hash.trim().to_lowercase();
188 let Some(algo) = HashAlgorithm::from_hex_len(normalized.len()) else {
189 return false;
190 };
191 match algo {
192 HashAlgorithm::Md5 => self.good_md5.contains(&normalized),
193 HashAlgorithm::Sha1 => self.good_sha1.contains(&normalized),
194 HashAlgorithm::Sha256 => self.good_sha256.contains(&normalized),
195 }
196 }
197
198 pub fn load_bad_from_file(&mut self, path: &Path) -> Result<usize, FeedError> {
202 self.load_from_file(path, true)
203 }
204
205 pub fn load_good_from_file(&mut self, path: &Path) -> Result<usize, FeedError> {
208 self.load_from_file(path, false)
209 }
210
211 fn load_from_file(&mut self, path: &Path, bad: bool) -> Result<usize, FeedError> {
212 let reader = std::io::BufReader::new(std::fs::File::open(path)?);
213 let mut count = 0;
214 for line in reader.lines() {
215 let line = line?;
216 let trimmed = line.trim();
217 if trimmed.is_empty() || trimmed.starts_with('#') {
218 continue;
219 }
220 let hash = trimmed.split([',', '\t', ' ']).next().unwrap_or(trimmed);
221 let inserted = if bad {
222 self.insert_bad(hash)
223 } else {
224 self.insert_good(hash)
225 };
226 if inserted.is_ok() {
227 count += 1;
228 }
229 }
230 Ok(count)
231 }
232
233 #[must_use]
235 pub fn bad_count(&self) -> usize {
236 self.bad_md5.len() + self.bad_sha1.len() + self.bad_sha256.len()
237 }
238
239 #[must_use]
241 pub fn good_count(&self) -> usize {
242 self.good_md5.len() + self.good_sha1.len() + self.good_sha256.len()
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::{HashAlgorithm, HashFeed};
249 use std::io::Write;
250
251 #[test]
252 fn algorithm_detects_by_hex_length() {
253 assert_eq!(HashAlgorithm::from_hex_len(32), Some(HashAlgorithm::Md5));
254 assert_eq!(HashAlgorithm::from_hex_len(40), Some(HashAlgorithm::Sha1));
255 assert_eq!(HashAlgorithm::from_hex_len(64), Some(HashAlgorithm::Sha256));
256 assert_eq!(HashAlgorithm::from_hex_len(10), None);
257 assert_eq!(HashAlgorithm::Md5.hex_len(), 32);
258 assert_eq!(HashAlgorithm::Sha256.name(), "SHA256");
259 }
260
261 #[test]
262 fn insert_and_lookup_bad_across_algorithms() {
263 let mut feed = HashFeed::new("test-feed");
264 assert_eq!(feed.name(), "test-feed");
265 let md5 = "a".repeat(32);
266 let sha1 = "b".repeat(40);
267 let sha256 = "c".repeat(64);
268 feed.insert_bad(&md5).unwrap();
269 feed.insert_bad(&sha1).unwrap();
270 feed.insert_bad(&sha256).unwrap();
271 assert_eq!(feed.bad_count(), 3);
272
273 let m = feed.lookup_bad(&sha256).unwrap();
274 assert_eq!(m.algorithm, HashAlgorithm::Sha256);
275 assert_eq!(m.source, "test-feed");
276 assert_eq!(m.hash, sha256);
277 assert!(feed.lookup_bad(&"d".repeat(64)).is_none());
278 }
279
280 #[test]
281 fn lookup_is_case_and_whitespace_insensitive() {
282 let mut feed = HashFeed::new("f");
283 let sha256 = "AB".repeat(32); feed.insert_bad(&sha256).unwrap();
285 assert!(feed
286 .lookup_bad(&format!(" {} ", sha256.to_lowercase()))
287 .is_some());
288 }
289
290 #[test]
291 fn known_good_filtering() {
292 let mut feed = HashFeed::new("nsrl-subset");
293 let good = "1".repeat(64);
294 feed.insert_good(&good).unwrap();
295 assert!(feed.is_known_good(&good));
296 assert!(feed.is_known_good(&good.to_uppercase()));
297 assert!(!feed.is_known_good(&"2".repeat(64)));
298 assert!(!feed.is_known_good("not-a-hash"));
299 assert_eq!(feed.good_count(), 1);
300 }
301
302 #[test]
303 fn invalid_hash_is_rejected_loudly() {
304 let mut feed = HashFeed::new("f");
305 assert!(feed.insert_bad("xyz").is_err()); assert!(feed.insert_bad(&"z".repeat(64)).is_err()); assert_eq!(feed.bad_count(), 0);
308 }
309
310 #[test]
311 fn loads_bad_and_good_from_file_skipping_comments() {
312 let mut f = tempfile::NamedTempFile::new().unwrap();
313 writeln!(f, "# a comment").unwrap();
314 writeln!(f).unwrap();
315 writeln!(f, "{}", "a".repeat(64)).unwrap();
316 writeln!(f, "{},extra,columns", "b".repeat(32)).unwrap(); writeln!(f, "not-a-valid-hash").unwrap(); f.flush().unwrap();
319
320 let mut feed = HashFeed::new("file-feed");
321 let n = feed.load_bad_from_file(f.path()).unwrap();
322 assert_eq!(n, 2, "two valid hashes; comment/blank/invalid skipped");
323 assert!(feed.lookup_bad(&"a".repeat(64)).is_some());
324 assert!(feed.lookup_bad(&"b".repeat(32)).is_some());
325
326 let mut g = tempfile::NamedTempFile::new().unwrap();
328 writeln!(g, "{}", "e".repeat(64)).unwrap();
329 g.flush().unwrap();
330 let mut good = HashFeed::new("good-file");
331 assert_eq!(good.load_good_from_file(g.path()).unwrap(), 1);
332 assert!(good.is_known_good(&"e".repeat(64)));
333 }
334}