Skip to main content

forensic_hashdb/
known_good.rs

1use std::path::Path;
2
3use memmap2::Mmap;
4
5/// Error type for `KnownGoodDb` operations.
6#[derive(Debug)]
7pub enum KnownGoodError {
8    Io(std::io::Error),
9    InvalidFileSize { bytes: u64 },
10}
11
12impl std::fmt::Display for KnownGoodError {
13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14        match self {
15            Self::Io(e) => write!(f, "I/O error: {e}"),
16            Self::InvalidFileSize { bytes } => {
17                write!(f, "file size {bytes} is not a multiple of 32 bytes")
18            }
19        }
20    }
21}
22
23impl std::error::Error for KnownGoodError {}
24
25impl From<std::io::Error> for KnownGoodError {
26    fn from(e: std::io::Error) -> Self {
27        Self::Io(e)
28    }
29}
30
31/// Zero-false-positive known-good file hash database.
32///
33/// Backed by a memory-mapped binary file containing sorted 32-byte SHA-256
34/// hashes. Binary search provides exact matching — no probabilistic structure,
35/// making it safe for forensic exclusion decisions.
36pub struct KnownGoodDb {
37    /// `None` when the file is empty (zero hashes).
38    mmap: Option<Mmap>,
39}
40
41impl KnownGoodDb {
42    /// Open a binary file containing sorted 32-byte SHA-256 hashes.
43    /// File must be a multiple of 32 bytes.
44    pub fn open(path: &Path) -> Result<Self, KnownGoodError> {
45        let file = std::fs::File::open(path)?;
46        let meta = file.metadata()?;
47        let bytes = meta.len();
48        if bytes % 32 != 0 {
49            return Err(KnownGoodError::InvalidFileSize { bytes });
50        }
51        if bytes == 0 {
52            return Ok(Self { mmap: None });
53        }
54        // SAFETY: file is opened read-only; caller is responsible for not
55        // modifying the file while this mapping is live (standard mmap contract).
56        // This is the one bounded `unsafe` this crate needs (mmap of the hash
57        // DB); `unsafe_code = "deny"` workspace-wide, allowed only here.
58        #[allow(unsafe_code)]
59        let mmap = unsafe { memmap2::MmapOptions::new().map(&file)? };
60        Ok(Self { mmap: Some(mmap) })
61    }
62
63    /// Returns `true` iff the hash is present in the database.
64    /// Zero false positives — safe for forensic exclusion decisions.
65    pub fn is_known_good(&self, sha256: &[u8; 32]) -> bool {
66        let Some(ref mmap) = self.mmap else {
67            return false;
68        };
69        let count = mmap.len() / 32;
70        let data = mmap.as_ref();
71        let mut lo = 0usize;
72        let mut hi = count;
73        while lo < hi {
74            let mid = lo + (hi - lo) / 2;
75            // mid < count == data.len()/32, so the 32-byte slice is always in
76            // range; the guard degrades to a non-match instead of panicking if
77            // that invariant is ever violated.
78            let Some(entry) = data
79                .get(mid * 32..(mid + 1) * 32)
80                .and_then(|s| <&[u8; 32]>::try_from(s).ok())
81            else {
82                return false; // cov:unreachable: mid < count bounds the slice
83            };
84            match entry.cmp(sha256) {
85                std::cmp::Ordering::Equal => return true,
86                std::cmp::Ordering::Less => lo = mid + 1,
87                std::cmp::Ordering::Greater => hi = mid,
88            }
89        }
90        false
91    }
92
93    /// Number of hashes in the database.
94    pub fn len(&self) -> usize {
95        self.mmap.as_ref().map_or(0, |m| m.len() / 32)
96    }
97
98    pub fn is_empty(&self) -> bool {
99        self.len() == 0
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::io::Write;
107
108    fn write_sorted_hashes(hashes: &mut [[u8; 32]]) -> tempfile::NamedTempFile {
109        hashes.sort_unstable();
110        let mut f = tempfile::NamedTempFile::new().unwrap();
111        for h in hashes.iter() {
112            f.write_all(h).unwrap();
113        }
114        f
115    }
116
117    #[test]
118    fn known_good_exact_hit_returns_true() {
119        let mut hashes: Vec<[u8; 32]> = vec![[0xaau8; 32], [0xbbu8; 32], [0xccu8; 32]];
120        let f = write_sorted_hashes(&mut hashes);
121        let db = KnownGoodDb::open(f.path()).unwrap();
122        assert!(db.is_known_good(&[0xaau8; 32]));
123    }
124
125    #[test]
126    fn known_good_miss_returns_false() {
127        let mut hashes: Vec<[u8; 32]> = vec![[0xaau8; 32], [0xbbu8; 32]];
128        let f = write_sorted_hashes(&mut hashes);
129        let db = KnownGoodDb::open(f.path()).unwrap();
130        assert!(!db.is_known_good(&[0xccu8; 32]));
131    }
132
133    #[test]
134    fn known_good_empty_db_returns_false() {
135        let mut hashes: Vec<[u8; 32]> = vec![];
136        let f = write_sorted_hashes(&mut hashes);
137        let db = KnownGoodDb::open(f.path()).unwrap();
138        assert!(!db.is_known_good(&[0x01u8; 32]));
139    }
140
141    #[test]
142    fn known_good_invalid_file_size_returns_error() {
143        let mut f = tempfile::NamedTempFile::new().unwrap();
144        f.write_all(&[0u8; 31]).unwrap(); // 31 bytes — not a multiple of 32
145        let result = KnownGoodDb::open(f.path());
146        assert!(
147            matches!(result, Err(KnownGoodError::InvalidFileSize { bytes: 31 })),
148            "expected InvalidFileSize error"
149        );
150    }
151
152    #[test]
153    fn known_good_first_entry_found() {
154        let mut hashes: Vec<[u8; 32]> = vec![[0x01u8; 32], [0x80u8; 32], [0xffu8; 32]];
155        let f = write_sorted_hashes(&mut hashes);
156        let db = KnownGoodDb::open(f.path()).unwrap();
157        // After sort, 0x01 is first
158        assert!(db.is_known_good(&[0x01u8; 32]));
159    }
160
161    #[test]
162    fn known_good_last_entry_found() {
163        let mut hashes: Vec<[u8; 32]> = vec![[0x01u8; 32], [0x80u8; 32], [0xffu8; 32]];
164        let f = write_sorted_hashes(&mut hashes);
165        let db = KnownGoodDb::open(f.path()).unwrap();
166        // After sort, 0xff is last
167        assert!(db.is_known_good(&[0xffu8; 32]));
168    }
169
170    #[test]
171    fn known_good_len_correct() {
172        let mut hashes: Vec<[u8; 32]> = vec![[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
173        let f = write_sorted_hashes(&mut hashes);
174        let db = KnownGoodDb::open(f.path()).unwrap();
175        assert_eq!(db.len(), 3);
176    }
177}