Skip to main content

forensic_hashdb/
feed.rs

1//! Analyst-supplied hash feeds — the ad-hoc counterpart to the curated DBs.
2//!
3//! Where [`crate::known_good`] and [`crate::known_bad`] are curated SHA-256
4//! reference databases, a [`HashFeed`] holds *analyst-supplied* known-good and
5//! known-bad hashes loaded at runtime from a text or CSV file (one hash per line).
6//! It is multi-algorithm — MD5, SHA-1, or SHA-256, auto-detected by hex length —
7//! and pure `std` (it stores hex strings; it does not compute digests).
8//!
9//! This is the store that used to live as `issen-signatures`'
10//! `HashIocStore`; it moved here so hash lookup has one home (ADR-0011).
11
12use std::collections::HashSet;
13use std::io::BufRead;
14use std::path::Path;
15
16/// The hash algorithm, distinguished by hex-string length.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum HashAlgorithm {
19    Md5,
20    Sha1,
21    Sha256,
22}
23
24impl HashAlgorithm {
25    /// Expected hex-string length for this algorithm.
26    #[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    /// Detect the algorithm from a hex-string length (`None` if it matches none).
36    #[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    /// Display name.
47    #[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/// A rejected hash (wrong length or non-hex characters).
58#[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/// A known-bad match, with the source feed's label for provenance.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct HashMatch {
88    pub hash: String,
89    pub algorithm: HashAlgorithm,
90    pub source: String,
91}
92
93/// An analyst-supplied multi-algorithm known-good / known-bad hash set.
94///
95/// Hashes are stored as lowercase hex strings, partitioned by algorithm
96/// (auto-detected by length). Lookups normalize case and surrounding whitespace.
97#[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    /// An empty feed carrying a provenance label.
110    #[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    /// The provenance label.
124    #[must_use]
125    pub fn name(&self) -> &str {
126        &self.source_label
127    }
128
129    /// Normalize + validate a hash, returning `(normalized, algorithm)`.
130    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    /// Insert a known-bad hash (algorithm auto-detected).
146    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    /// Insert a known-good hash (algorithm auto-detected).
157    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    /// Look up a known-bad hash, returning a [`HashMatch`] on a hit.
168    #[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    /// True if the hash is in the known-good set (e.g. an NSRL subset).
185    #[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    /// Load known-bad hashes from a text/CSV file (one per line; `#` comments and
199    /// blank lines skipped; the first comma/tab/space-delimited field is taken).
200    /// Returns the number of valid hashes ingested; malformed lines are skipped.
201    pub fn load_bad_from_file(&mut self, path: &Path) -> Result<usize, FeedError> {
202        self.load_from_file(path, true)
203    }
204
205    /// Load known-good hashes from a text/CSV file (same format as
206    /// [`HashFeed::load_bad_from_file`]).
207    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    /// Total known-bad hashes across all algorithms.
234    #[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    /// Total known-good hashes across all algorithms.
240    #[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); // 64 hex chars, uppercase
284        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()); // wrong length
306        assert!(feed.insert_bad(&"z".repeat(64)).is_err()); // right length, non-hex
307        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(); // CSV: first field only
317        writeln!(f, "not-a-valid-hash").unwrap(); // skipped, not counted
318        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        // The good loader reads the same one-per-line format into the good set.
327        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}