wowlab_sentinel/utils/
bloom.rs1#![cfg_attr(
2 test,
3 expect(
4 clippy::cast_possible_truncation,
5 clippy::cast_precision_loss,
6 clippy::cast_sign_loss,
7 reason = "Bloom filter sizing follows the standard floating-point formulas and clamps allocation bounds"
8 )
9)]
10
11use sha2::{Digest, Sha256};
12
13const BITS_PER_BYTE: u64 = 8;
14const HASH_HALF_BYTES: usize = 8;
15const HASH_SECOND_OFFSET: usize = 16;
16
17#[cfg(test)]
18const LN_2: f64 = 2.0_f64;
19
20#[derive(Debug)]
22pub(crate) struct BloomFilter {
23 bits: Vec<u8>,
24 num_bits: u64,
25 num_hashes: u32,
26}
27
28impl BloomFilter {
29 #[cfg(test)]
30 pub(crate) fn new(n: usize, fp_rate: f64) -> Self {
31 assert!(n > 0, "n must be > 0");
32 assert!(fp_rate > 0.0 && fp_rate < 1.0, "fp_rate must be in (0, 1)");
33
34 let raw_bits = optimal_num_bits(n, fp_rate);
35 let num_bytes = (raw_bits as usize).div_ceil(BITS_PER_BYTE as usize);
36 let num_bits = num_bytes as u64 * BITS_PER_BYTE;
38 let num_hashes = optimal_num_hashes(num_bits, n);
39
40 Self {
41 bits: vec![0u8; num_bytes],
42 num_bits,
43 num_hashes,
44 }
45 }
46
47 #[cfg(test)]
48 pub(crate) fn from_bytes(bytes: Vec<u8>, member_count: usize) -> Self {
49 let num_bits = bytes.len() as u64 * BITS_PER_BYTE;
50 let num_hashes = optimal_num_hashes(num_bits, member_count);
51
52 Self {
53 bits: bytes,
54 num_bits,
55 num_hashes,
56 }
57 }
58
59 #[cfg(test)]
60 pub(crate) fn insert(&mut self, item: &str) {
61 let (h1, h2) = hash_item(item);
62
63 for i in 0..self.num_hashes {
64 let pos = self.position(h1, h2, i);
65
66 self.set_bit(pos);
67 }
68 }
69
70 pub(crate) fn might_contain(&self, item: &str) -> bool {
71 let (h1, h2) = hash_item(item);
72
73 for i in 0..self.num_hashes {
74 let pos = self.position(h1, h2, i);
75
76 if !self.get_bit(pos) {
77 return false;
78 }
79 }
80
81 true
82 }
83
84 #[cfg(test)]
85 pub(crate) fn as_bytes(&self) -> &[u8] {
86 &self.bits
87 }
88
89 #[cfg(test)]
90 pub(crate) fn into_bytes(self) -> Vec<u8> {
91 self.bits
92 }
93
94 fn position(&self, h1: u64, h2: u64, i: u32) -> u64 {
95 h1.wrapping_add(u64::from(i).wrapping_mul(h2)) % self.num_bits
96 }
97
98 #[cfg(test)]
100 fn set_bit(&mut self, pos: u64) {
101 let byte_idx = (pos / BITS_PER_BYTE) as usize;
102 let bit_idx = (pos % BITS_PER_BYTE) as u8;
103
104 self.bits[byte_idx] |= 1 << bit_idx;
105 }
106
107 fn get_bit(&self, pos: u64) -> bool {
109 let byte_idx = (pos / BITS_PER_BYTE) as usize;
110 let bit_idx = (pos % BITS_PER_BYTE) as u8;
111
112 (self.bits[byte_idx] >> bit_idx) & 1 == 1
113 }
114}
115
116fn hash_item(item: &str) -> (u64, u64) {
118 let hash = Sha256::digest(item.as_bytes());
119 let h1 = u64::from_le_bytes(
120 hash[0..HASH_HALF_BYTES]
121 .try_into()
122 .expect("8-byte slice from SHA-256"),
123 );
124 let h2 = u64::from_le_bytes(
125 hash[HASH_HALF_BYTES..HASH_SECOND_OFFSET]
126 .try_into()
127 .expect("8-byte slice from SHA-256"),
128 );
129
130 (h1, h2)
131}
132
133#[cfg(test)]
134fn optimal_num_bits(n: usize, fp_rate: f64) -> u64 {
135 let m = -(n as f64) * fp_rate.ln() / (LN_2.ln().powi(2));
137
138 m.ceil() as u64
139}
140
141#[cfg(test)]
142fn optimal_num_hashes(num_bits: u64, n: usize) -> u32 {
143 let k = (num_bits as f64 / n as f64) * LN_2.ln();
144 let k = k.round() as u32;
145
146 k.max(1)
147}
148
149#[cfg(test)]
150const FP_RATE: f64 = 0.001;
151
152#[cfg(test)]
153fn create_server_filter(discord_ids: &[String]) -> BloomFilter {
154 let mut filter = BloomFilter::new(discord_ids.len().max(1), FP_RATE);
155
156 for id in discord_ids {
157 filter.insert(id);
158 }
159
160 filter
161}
162
163#[cfg(test)]
164pub(crate) fn filter_hash(bytes: &[u8]) -> String {
165 let hash = Sha256::digest(bytes);
166 hex::encode(&hash[..HASH_HALF_BYTES])
169}
170
171#[cfg(test)]
172mod tests {
173 use base64::Engine;
174 use googletest::prelude::*;
175
176 use super::*;
177
178 #[gtest]
179 fn test_insert_and_contains() -> Result<()> {
180 let ids: Vec<String> = (0..100)
181 .map(|i| format!("{}", 100_000_000_000_000_000_u64 + i))
182 .collect();
183 let filter = create_server_filter(&ids);
184
185 for id in &ids {
186 verify_true!(filter.might_contain(id))?;
187 }
188
189 Ok(())
190 }
191
192 #[gtest]
193 fn test_false_positive_rate() -> Result<()> {
194 let n = 1000;
195 let ids: Vec<String> = (0..n)
196 .map(|i| format!("{}", 100_000_000_000_000_000_u64 + i))
197 .collect();
198 let filter = create_server_filter(&ids);
199
200 let mut false_positives = 0;
201
202 for i in n..(n + 10000) {
203 let id = format!("{}", 100_000_000_000_000_000_u64 + i);
204
205 if filter.might_contain(&id) {
206 false_positives += 1;
207 }
208 }
209
210 let fp_rate = f64::from(false_positives) / 10000.0;
211
212 verify_true!(fp_rate < 0.005)?;
213
214 Ok(())
215 }
216
217 #[gtest]
218 fn test_roundtrip_from_bytes() -> Result<()> {
219 let ids: Vec<String> = (0..500)
220 .map(|i| format!("{}", 200_000_000_000_000_000_u64 + i))
221 .collect();
222 let filter = create_server_filter(&ids);
223 let bytes = filter.into_bytes();
224
225 let restored = BloomFilter::from_bytes(bytes, 500);
226
227 for id in &ids {
228 verify_true!(restored.might_contain(id))?;
229 }
230
231 Ok(())
232 }
233
234 #[gtest]
235 fn test_interop_vectors() -> Result<()> {
236 let ids = vec![
237 "123456789012345678".to_string(),
238 "987654321098765432".to_string(),
239 "111222333444555666".to_string(),
240 ];
241 let filter = create_server_filter(&ids);
242 let bytes = filter.as_bytes();
243 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
244 let hash = filter_hash(bytes);
245
246 eprintln!("interop vector:");
247 eprintln!(" base64: {b64}");
248 eprintln!(" hash: {hash}");
249 eprintln!(" byte_len: {}", bytes.len());
250
251 verify_true!(filter.might_contain("123456789012345678"))?;
252 verify_true!(filter.might_contain("987654321098765432"))?;
253 verify_true!(filter.might_contain("111222333444555666"))?;
254
255 verify_true!(!filter.might_contain("000000000000000000"))?;
256 verify_true!(!filter.might_contain("999999999999999999"))?;
257
258 verify_eq!(bytes.len(), 6)?;
259 verify_eq!(hash, "554721ba80ba8f66")?;
260 verify_eq!(b64, "E5Nn44kd")?;
261
262 Ok(())
263 }
264
265 #[gtest]
266 fn test_filter_sizing() -> Result<()> {
267 let ids: Vec<String> = (0..1000).map(|i| format!("{i}")).collect();
268 let filter = create_server_filter(&ids);
269 let size = filter.as_bytes().len();
270
271 verify_true!(size > 1500 && size < 2500)?;
272
273 Ok(())
274 }
275}