Skip to main content

forensic_mount/
filter.rs

1#![allow(dead_code)]
2
3use md5::{Digest, Md5};
4use std::collections::HashSet;
5use std::io;
6use std::path::Path;
7
8/// Compute MD5 hash of data, returning lowercase hex string.
9pub fn compute_md5(data: &[u8]) -> String {
10    let mut hasher = Md5::new();
11    hasher.update(data);
12    format!("{:x}", hasher.finalize())
13}
14
15/// A known-good hash database for filtering.
16pub trait FilterDb {
17    /// Check if an MD5 hash exists in this database.
18    fn contains_md5(&self, md5: &str) -> bool;
19}
20
21/// Plain text file with one MD5 hash per line.
22pub struct CustomDb {
23    hashes: HashSet<String>,
24}
25
26impl CustomDb {
27    pub fn load(path: &Path) -> io::Result<Self> {
28        let content = std::fs::read_to_string(path)?;
29        let hashes: HashSet<String> = content
30            .lines()
31            .map(|line| line.trim().to_lowercase())
32            .filter(|line| !line.is_empty() && !line.starts_with('#'))
33            .collect();
34        Ok(Self { hashes })
35    }
36}
37
38impl FilterDb for CustomDb {
39    fn contains_md5(&self, md5: &str) -> bool {
40        self.hashes.contains(&md5.to_lowercase())
41    }
42}
43
44/// `HashKeeper` format: lines of "`file_id,directory_id,file_name,filesize,md5`"
45/// or simpler format with just MD5 + filename separated by comma/tab.
46pub struct HashKeeperDb {
47    hashes: HashSet<String>,
48}
49
50impl HashKeeperDb {
51    pub fn load(path: &Path) -> io::Result<Self> {
52        let content = std::fs::read_to_string(path)?;
53        let mut hashes = HashSet::new();
54        for line in content.lines() {
55            let line = line.trim();
56            if line.is_empty() || line.starts_with('#') || line.starts_with('%') {
57                continue;
58            }
59            // Try to extract MD5 — it's 32 hex chars
60            // HashKeeper format varies, but MD5 is usually the last or a specific column
61            for field in line.split([',', '\t']) {
62                let field = field.trim().to_lowercase();
63                if field.len() == 32 && field.chars().all(|c| c.is_ascii_hexdigit()) {
64                    hashes.insert(field);
65                    break;
66                }
67            }
68        }
69        Ok(Self { hashes })
70    }
71}
72
73impl FilterDb for HashKeeperDb {
74    fn contains_md5(&self, md5: &str) -> bool {
75        self.hashes.contains(&md5.to_lowercase())
76    }
77}
78
79/// NSRL `RDSv3` `SQLite` database.
80pub struct NsrlDb {
81    hashes: HashSet<String>,
82}
83
84impl NsrlDb {
85    /// Load NSRL database by reading all MD5 hashes into memory.
86    /// For `RDSv3` `SQLite`: SELECT md5 FROM FILE (or similar table).
87    /// Falls back to treating the file as a plain text hash list.
88    pub fn load(path: &Path) -> io::Result<Self> {
89        // Try SQLite first
90        if let Ok(db) = Self::load_sqlite(path) {
91            return Ok(db);
92        }
93        // Fall back to text format
94        let custom = CustomDb::load(path)?;
95        Ok(Self {
96            hashes: custom.hashes,
97        })
98    }
99
100    fn load_sqlite(path: &Path) -> io::Result<Self> {
101        let conn =
102            rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
103                .map_err(io::Error::other)?;
104
105        let mut hashes = HashSet::new();
106
107        // RDSv3 schema: FILE table has md5 column
108        // Try common table/column names
109        for query in &[
110            "SELECT md5 FROM FILE",
111            "SELECT md5 FROM file",
112            "SELECT MD5 FROM FILE",
113            "SELECT hash FROM hashes WHERE type='md5'",
114        ] {
115            if let Ok(mut stmt) = conn.prepare(query) {
116                let rows = stmt.query_map([], |row| row.get::<_, String>(0));
117                if let Ok(rows) = rows {
118                    for row in rows.flatten() {
119                        hashes.insert(row.to_lowercase());
120                    }
121                    if !hashes.is_empty() {
122                        break;
123                    }
124                }
125            }
126        }
127
128        if hashes.is_empty() {
129            return Err(io::Error::new(
130                io::ErrorKind::NotFound,
131                "no MD5 hashes found in SQLite DB",
132            ));
133        }
134
135        Ok(Self { hashes })
136    }
137}
138
139impl FilterDb for NsrlDb {
140    fn contains_md5(&self, md5: &str) -> bool {
141        self.hashes.contains(&md5.to_lowercase())
142    }
143}
144
145/// Aggregate filter that checks multiple databases.
146pub struct FilterChain {
147    dbs: Vec<Box<dyn FilterDb>>,
148}
149
150impl Default for FilterChain {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl FilterChain {
157    pub fn new() -> Self {
158        Self { dbs: Vec::new() }
159    }
160
161    pub fn add(&mut self, db: Box<dyn FilterDb>) {
162        self.dbs.push(db);
163    }
164
165    pub fn is_empty(&self) -> bool {
166        self.dbs.is_empty()
167    }
168}
169
170impl FilterDb for FilterChain {
171    fn contains_md5(&self, md5: &str) -> bool {
172        self.dbs.iter().any(|db| db.contains_md5(md5))
173    }
174}
175
176/// Cached filter results for persistence across sessions.
177#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
178pub struct FilterCache {
179    /// Map of ext4 inode -> (`md5_hash`, `is_known`)
180    pub entries: std::collections::HashMap<u64, FilterCacheEntry>,
181}
182
183#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
184pub struct FilterCacheEntry {
185    pub md5: String,
186    pub is_known: bool,
187}
188
189impl FilterCache {
190    pub fn save(&self, path: &Path) -> io::Result<()> {
191        let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
192        std::fs::write(path, json)
193    }
194
195    pub fn load(path: &Path) -> io::Result<Self> {
196        let json = std::fs::read_to_string(path)?;
197        serde_json::from_str(&json).map_err(io::Error::other)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::io::Write;
205
206    #[test]
207    fn compute_md5_known_value() {
208        // MD5 of empty string
209        let hash = compute_md5(b"");
210        assert_eq!(hash, "d41d8cd98f00b204e9800998ecf8427e");
211    }
212
213    #[test]
214    fn compute_md5_hello() {
215        let hash = compute_md5(b"Hello, ext4!");
216        assert_eq!(hash.len(), 32);
217        // Deterministic
218        assert_eq!(hash, compute_md5(b"Hello, ext4!"));
219    }
220
221    #[test]
222    fn custom_db_lookup() {
223        let tmp = std::env::temp_dir().join("forensic_mount_test_custom_db.txt");
224        let mut f = std::fs::File::create(&tmp).unwrap();
225        writeln!(f, "d41d8cd98f00b204e9800998ecf8427e").unwrap();
226        writeln!(f, "# comment line").unwrap();
227        writeln!(f, "098f6bcd4621d373cade4e832627b4f6").unwrap();
228        drop(f);
229
230        let db = CustomDb::load(&tmp).unwrap();
231        assert!(db.contains_md5("d41d8cd98f00b204e9800998ecf8427e"));
232        assert!(db.contains_md5("098f6bcd4621d373cade4e832627b4f6"));
233        assert!(!db.contains_md5("0000000000000000000000000000000"));
234        // Case insensitive
235        assert!(db.contains_md5("D41D8CD98F00B204E9800998ECF8427E"));
236
237        let _ = std::fs::remove_file(&tmp);
238    }
239
240    #[test]
241    fn hashkeeper_db_lookup() {
242        let tmp = std::env::temp_dir().join("forensic_mount_test_hk_db.txt");
243        let mut f = std::fs::File::create(&tmp).unwrap();
244        writeln!(f, "% header line").unwrap();
245        writeln!(f, "1,2,file.txt,100,d41d8cd98f00b204e9800998ecf8427e").unwrap();
246        writeln!(f, "3,4,other.dll,200,098f6bcd4621d373cade4e832627b4f6").unwrap();
247        drop(f);
248
249        let db = HashKeeperDb::load(&tmp).unwrap();
250        assert!(db.contains_md5("d41d8cd98f00b204e9800998ecf8427e"));
251        assert!(db.contains_md5("098f6bcd4621d373cade4e832627b4f6"));
252        assert!(!db.contains_md5("ffffffffffffffffffffffffffffffff"));
253
254        let _ = std::fs::remove_file(&tmp);
255    }
256
257    #[test]
258    fn filter_chain_combines_dbs() {
259        let tmp1 = std::env::temp_dir().join("forensic_mount_test_chain1.txt");
260        let tmp2 = std::env::temp_dir().join("forensic_mount_test_chain2.txt");
261        std::fs::write(&tmp1, "d41d8cd98f00b204e9800998ecf8427e\n").unwrap();
262        std::fs::write(&tmp2, "098f6bcd4621d373cade4e832627b4f6\n").unwrap();
263
264        let mut chain = FilterChain::new();
265        chain.add(Box::new(CustomDb::load(&tmp1).unwrap()));
266        chain.add(Box::new(CustomDb::load(&tmp2).unwrap()));
267
268        assert!(chain.contains_md5("d41d8cd98f00b204e9800998ecf8427e"));
269        assert!(chain.contains_md5("098f6bcd4621d373cade4e832627b4f6"));
270        assert!(!chain.contains_md5("0000000000000000000000000000000"));
271
272        let _ = std::fs::remove_file(&tmp1);
273        let _ = std::fs::remove_file(&tmp2);
274    }
275
276    #[test]
277    fn filter_cache_roundtrip() {
278        let tmp = std::env::temp_dir().join("forensic_mount_test_filter_cache.json");
279        let mut cache = FilterCache::default();
280        cache.entries.insert(
281            12,
282            FilterCacheEntry {
283                md5: "d41d8cd98f00b204e9800998ecf8427e".to_string(),
284                is_known: true,
285            },
286        );
287        cache.save(&tmp).unwrap();
288
289        let loaded = FilterCache::load(&tmp).unwrap();
290        assert_eq!(loaded.entries.len(), 1);
291        assert!(loaded.entries[&12].is_known);
292
293        let _ = std::fs::remove_file(&tmp);
294    }
295}