mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Merge refactor-sans-io: converge next bloom onto v1-sans-IO
Forward-merge the master-line bloom relocation into the next line, discarding next's v1.5 bloom draft (RLE codec, XOR-diff delta, FilterNack/0x21, adaptive sizing) and converging both lines onto the identical proto/bloom v1-sans-IO module. This is a deliberate, temporary wire regression on the next line: the v2 bloom is rebuilt fresh, sans-IO from day one, on both lines later. Nothing outside the bloom feature depended on the v1.5-specific surface. proto/bloom is now byte-identical across both integration lines.
This commit is contained in:
@@ -1,224 +0,0 @@
|
||||
//! Word-level RLE compression for bloom filter data.
|
||||
//!
|
||||
//! Encodes a sequence of `u64` words using run-length encoding.
|
||||
//! Each run is encoded as `[count:2 LE][word:8 LE]` (10 bytes per run).
|
||||
//! Sparse data (XOR diffs with mostly zero words) compresses well.
|
||||
//!
|
||||
//! ## Delta vs Full Strategy
|
||||
//!
|
||||
//! The sender tracks `last_sent_filter` per peer. When a new filter is
|
||||
//! ready, the sender XORs it with the last-sent filter to produce a diff.
|
||||
//! The diff is mostly zero words (only changed bits set), which RLE
|
||||
//! compresses efficiently. If no previous filter exists (first send,
|
||||
//! size class change, or NACK recovery), a full filter is sent instead.
|
||||
//!
|
||||
//! The same RLE codec handles both cases — full filters at ~25% fill
|
||||
//! still benefit from zero-word runs between set regions.
|
||||
//!
|
||||
//! ## NACK Recovery
|
||||
//!
|
||||
//! If the receiver detects a sequence gap (missed delta), it sends a
|
||||
//! NACK. The sender responds with a full filter, resetting the delta
|
||||
//! baseline for that peer.
|
||||
|
||||
/// Statistics from a compression operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CompressionStats {
|
||||
/// Number of u64 words in the uncompressed input.
|
||||
pub raw_words: usize,
|
||||
/// Number of bytes in the compressed output.
|
||||
pub compressed_bytes: usize,
|
||||
/// Number of distinct runs in the encoding.
|
||||
pub run_count: usize,
|
||||
}
|
||||
|
||||
/// RLE-encode a slice of u64 words.
|
||||
///
|
||||
/// Returns the compressed bytes and compression statistics.
|
||||
/// Each run is encoded as `[count:2 LE][word:8 LE]`.
|
||||
/// Maximum run length is `u16::MAX` (65535); longer runs are split.
|
||||
pub fn rle_encode(words: &[u64]) -> (Vec<u8>, CompressionStats) {
|
||||
let mut buf = Vec::new();
|
||||
let mut run_count = 0usize;
|
||||
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
let value = words[i];
|
||||
let mut count = 1u16;
|
||||
while i + (count as usize) < words.len()
|
||||
&& words[i + count as usize] == value
|
||||
&& count < u16::MAX
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
|
||||
buf.extend_from_slice(&count.to_le_bytes());
|
||||
buf.extend_from_slice(&value.to_le_bytes());
|
||||
run_count += 1;
|
||||
i += count as usize;
|
||||
}
|
||||
|
||||
let stats = CompressionStats {
|
||||
raw_words: words.len(),
|
||||
compressed_bytes: buf.len(),
|
||||
run_count,
|
||||
};
|
||||
(buf, stats)
|
||||
}
|
||||
|
||||
/// RLE-decode compressed bytes back to u64 words.
|
||||
///
|
||||
/// `expected_words` is the expected number of output words (for validation).
|
||||
/// Returns an error if the data is truncated or the decoded length doesn't
|
||||
/// match the expected count.
|
||||
pub fn rle_decode(data: &[u8], expected_words: usize) -> Result<Vec<u64>, RleError> {
|
||||
let mut words = Vec::with_capacity(expected_words);
|
||||
let mut pos = 0;
|
||||
|
||||
while pos + 10 <= data.len() {
|
||||
let count = u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()) as usize;
|
||||
let value = u64::from_le_bytes(data[pos + 2..pos + 10].try_into().unwrap());
|
||||
pos += 10;
|
||||
|
||||
if words.len() + count > expected_words {
|
||||
return Err(RleError::DecodedTooLarge {
|
||||
expected: expected_words,
|
||||
got: words.len() + count,
|
||||
});
|
||||
}
|
||||
|
||||
words.extend(std::iter::repeat_n(value, count));
|
||||
}
|
||||
|
||||
if pos != data.len() {
|
||||
return Err(RleError::TruncatedInput {
|
||||
remaining: data.len() - pos,
|
||||
});
|
||||
}
|
||||
|
||||
if words.len() != expected_words {
|
||||
return Err(RleError::DecodedSizeMismatch {
|
||||
expected: expected_words,
|
||||
got: words.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
/// Errors from RLE decoding.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RleError {
|
||||
#[error("truncated RLE input: {remaining} trailing bytes")]
|
||||
TruncatedInput { remaining: usize },
|
||||
|
||||
#[error("decoded size mismatch: expected {expected} words, got {got}")]
|
||||
DecodedSizeMismatch { expected: usize, got: usize },
|
||||
|
||||
#[error("decoded data too large: expected {expected} words, got {got}")]
|
||||
DecodedTooLarge { expected: usize, got: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rle_round_trip_all_zero() {
|
||||
let words = vec![0u64; 128]; // 1KB filter
|
||||
let (encoded, stats) = rle_encode(&words);
|
||||
|
||||
// All zeros = 1 run of 128 zero words = 10 bytes
|
||||
assert_eq!(stats.run_count, 1);
|
||||
assert_eq!(stats.compressed_bytes, 10);
|
||||
assert_eq!(stats.raw_words, 128);
|
||||
|
||||
let decoded = rle_decode(&encoded, 128).unwrap();
|
||||
assert_eq!(decoded, words);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rle_round_trip_random() {
|
||||
// Non-uniform data: each word different
|
||||
let words: Vec<u64> = (0..64).map(|i| i * 0x0123456789ABCDEF).collect();
|
||||
let (encoded, stats) = rle_encode(&words);
|
||||
|
||||
assert_eq!(stats.raw_words, 64);
|
||||
assert_eq!(stats.run_count, 64); // no compression
|
||||
assert_eq!(stats.compressed_bytes, 64 * 10);
|
||||
|
||||
let decoded = rle_decode(&encoded, 64).unwrap();
|
||||
assert_eq!(decoded, words);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rle_round_trip_sparse_diff() {
|
||||
// Simulate XOR diff: mostly zeros with a few set words
|
||||
let mut words = vec![0u64; 128];
|
||||
words[10] = 0xFF00FF00;
|
||||
words[50] = 0xDEADBEEF;
|
||||
words[127] = 0x1;
|
||||
|
||||
let (encoded, stats) = rle_encode(&words);
|
||||
|
||||
// Should compress well: ~7 runs
|
||||
assert!(stats.run_count < 10);
|
||||
assert!(stats.compressed_bytes < 128 * 8); // much smaller than raw
|
||||
|
||||
let decoded = rle_decode(&encoded, 128).unwrap();
|
||||
assert_eq!(decoded, words);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rle_empty_input() {
|
||||
let (encoded, stats) = rle_encode(&[]);
|
||||
assert_eq!(stats.raw_words, 0);
|
||||
assert_eq!(stats.compressed_bytes, 0);
|
||||
assert_eq!(stats.run_count, 0);
|
||||
|
||||
let decoded = rle_decode(&encoded, 0).unwrap();
|
||||
assert!(decoded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rle_decode_truncated() {
|
||||
// 5 bytes is not a complete run (need 10)
|
||||
let bad_data = vec![0u8; 5];
|
||||
let result = rle_decode(&bad_data, 1);
|
||||
assert!(matches!(result, Err(RleError::TruncatedInput { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rle_decode_size_mismatch() {
|
||||
let words = vec![0u64; 10];
|
||||
let (encoded, _) = rle_encode(&words);
|
||||
|
||||
// Expect wrong number of words
|
||||
let result = rle_decode(&encoded, 20);
|
||||
assert!(matches!(result, Err(RleError::DecodedSizeMismatch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rle_decode_too_large() {
|
||||
let words = vec![0u64; 100];
|
||||
let (encoded, _) = rle_encode(&words);
|
||||
|
||||
// Expect fewer words than what's encoded
|
||||
let result = rle_decode(&encoded, 50);
|
||||
assert!(matches!(result, Err(RleError::DecodedTooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rle_compression_stats() {
|
||||
// 3 runs: 10 zeros, 5 ones, 5 zeros
|
||||
let mut words = vec![0u64; 20];
|
||||
for w in &mut words[10..15] {
|
||||
*w = u64::MAX;
|
||||
}
|
||||
|
||||
let (_, stats) = rle_encode(&words);
|
||||
assert_eq!(stats.raw_words, 20);
|
||||
assert_eq!(stats.run_count, 3);
|
||||
assert_eq!(stats.compressed_bytes, 30); // 3 * 10
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
//! Generic Bloom filter data structure.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use tracing::trace;
|
||||
|
||||
use super::{
|
||||
BloomError, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT, MAX_SIZE_CLASS, MIN_SIZE_CLASS,
|
||||
SIZE_CLASS_BYTES,
|
||||
};
|
||||
use crate::NodeAddr;
|
||||
|
||||
/// A Bloom filter for probabilistic set membership.
|
||||
///
|
||||
/// Used in FIPS to track which destinations are reachable through a peer.
|
||||
/// The filter uses double hashing to generate k hash functions from two
|
||||
/// base hashes derived from the input.
|
||||
///
|
||||
/// Internal storage uses 64-bit words for efficient bitwise operations
|
||||
/// and word-level RLE compression.
|
||||
#[derive(Clone)]
|
||||
pub struct BloomFilter {
|
||||
/// Bit array storage (packed as 64-bit words, little-endian bit order).
|
||||
words: Vec<u64>,
|
||||
/// Number of bits in the filter.
|
||||
num_bits: usize,
|
||||
/// Number of hash functions to use.
|
||||
hash_count: u8,
|
||||
}
|
||||
|
||||
impl BloomFilter {
|
||||
/// Create a new empty Bloom filter with default parameters.
|
||||
pub fn new() -> Self {
|
||||
Self::with_params(DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT)
|
||||
.expect("default params are valid")
|
||||
}
|
||||
|
||||
/// Create a Bloom filter with custom parameters.
|
||||
pub fn with_params(num_bits: usize, hash_count: u8) -> Result<Self, BloomError> {
|
||||
if num_bits == 0 || !num_bits.is_multiple_of(8) {
|
||||
return Err(BloomError::SizeNotByteAligned(num_bits));
|
||||
}
|
||||
if !num_bits.is_multiple_of(64) {
|
||||
return Err(BloomError::SizeNotWordAligned(num_bits));
|
||||
}
|
||||
if hash_count == 0 {
|
||||
return Err(BloomError::ZeroHashCount);
|
||||
}
|
||||
|
||||
let num_words = num_bits / 64;
|
||||
Ok(Self {
|
||||
words: vec![0u64; num_words],
|
||||
num_bits,
|
||||
hash_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a Bloom filter from raw bytes (little-endian byte order).
|
||||
pub fn from_bytes(bytes: Vec<u8>, hash_count: u8) -> Result<Self, BloomError> {
|
||||
if hash_count == 0 {
|
||||
return Err(BloomError::ZeroHashCount);
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Err(BloomError::SizeNotByteAligned(0));
|
||||
}
|
||||
let num_bits = bytes.len() * 8;
|
||||
if !num_bits.is_multiple_of(64) {
|
||||
return Err(BloomError::SizeNotWordAligned(num_bits));
|
||||
}
|
||||
|
||||
let num_words = num_bits / 64;
|
||||
let mut words = Vec::with_capacity(num_words);
|
||||
for chunk in bytes.chunks_exact(8) {
|
||||
words.push(u64::from_le_bytes(chunk.try_into().unwrap()));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
words,
|
||||
num_bits,
|
||||
hash_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a Bloom filter from a byte slice.
|
||||
pub fn from_slice(bytes: &[u8], hash_count: u8) -> Result<Self, BloomError> {
|
||||
Self::from_bytes(bytes.to_vec(), hash_count)
|
||||
}
|
||||
|
||||
/// Insert a NodeAddr into the filter.
|
||||
pub fn insert(&mut self, node_addr: &NodeAddr) {
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.hash(node_addr.as_bytes(), i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert raw bytes into the filter.
|
||||
pub fn insert_bytes(&mut self, data: &[u8]) {
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.hash(data, i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the filter might contain a NodeAddr.
|
||||
///
|
||||
/// Returns `true` if the item might be in the set (possible false positive).
|
||||
/// Returns `false` if the item is definitely not in the set.
|
||||
pub fn contains(&self, node_addr: &NodeAddr) -> bool {
|
||||
self.contains_bytes(node_addr.as_bytes())
|
||||
}
|
||||
|
||||
/// Check if the filter might contain raw bytes.
|
||||
pub fn contains_bytes(&self, data: &[u8]) -> bool {
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.hash(data, i);
|
||||
if !self.get_bit(bit_index) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Merge another filter into this one (OR operation).
|
||||
///
|
||||
/// If the other filter is a different size, it is converted to this
|
||||
/// filter's size first (fold if larger, duplicate if smaller).
|
||||
/// After merge, this filter contains all elements from both filters.
|
||||
pub fn merge(&mut self, other: &BloomFilter) -> Result<(), BloomError> {
|
||||
if self.num_bits == other.num_bits {
|
||||
for (a, b) in self.words.iter_mut().zip(other.words.iter()) {
|
||||
*a |= b;
|
||||
}
|
||||
} else {
|
||||
let converted = other.convert_to(self.num_bits)?;
|
||||
for (a, b) in self.words.iter_mut().zip(converted.words.iter()) {
|
||||
*a |= b;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new filter that is the union of this and another.
|
||||
pub fn union(&self, other: &BloomFilter) -> Result<Self, BloomError> {
|
||||
let mut result = self.clone();
|
||||
result.merge(other)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Fold the filter in half (large → small).
|
||||
///
|
||||
/// ORs the top half of words with the bottom half, halving the filter
|
||||
/// size. No false negatives are introduced, but the fill ratio roughly
|
||||
/// doubles.
|
||||
pub fn fold(&self) -> Result<BloomFilter, BloomError> {
|
||||
let min_bits = SIZE_CLASS_BYTES[MIN_SIZE_CLASS as usize] * 8;
|
||||
if self.num_bits <= min_bits {
|
||||
return Err(BloomError::CannotFold(self.num_bits));
|
||||
}
|
||||
|
||||
let half = self.words.len() / 2;
|
||||
let words: Vec<u64> = self.words[..half]
|
||||
.iter()
|
||||
.zip(self.words[half..].iter())
|
||||
.map(|(a, b)| a | b)
|
||||
.collect();
|
||||
|
||||
Ok(BloomFilter {
|
||||
words,
|
||||
num_bits: self.num_bits / 2,
|
||||
hash_count: self.hash_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fold repeatedly to reach the target size in bits.
|
||||
pub fn fold_to(&self, target_bits: usize) -> Result<BloomFilter, BloomError> {
|
||||
if target_bits >= self.num_bits {
|
||||
return Err(BloomError::InvalidTargetSize(target_bits));
|
||||
}
|
||||
if !target_bits.is_power_of_two()
|
||||
|| target_bits < SIZE_CLASS_BYTES[MIN_SIZE_CLASS as usize] * 8
|
||||
{
|
||||
return Err(BloomError::InvalidTargetSize(target_bits));
|
||||
}
|
||||
|
||||
let mut result = self.fold()?;
|
||||
while result.num_bits > target_bits {
|
||||
result = result.fold()?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Duplicate the filter (small → large).
|
||||
///
|
||||
/// Concatenates the filter with itself, doubling the size.
|
||||
/// The duplicated filter is compatible with the larger hash space:
|
||||
/// `h(x) mod 2m` maps to either `h(x) mod m` or `h(x) mod m + m`,
|
||||
/// and both positions have the bit set.
|
||||
pub fn duplicate(&self) -> Result<BloomFilter, BloomError> {
|
||||
let max_bits = SIZE_CLASS_BYTES[MAX_SIZE_CLASS as usize] * 8;
|
||||
if self.num_bits >= max_bits {
|
||||
return Err(BloomError::CannotDuplicate(self.num_bits));
|
||||
}
|
||||
|
||||
let mut words = Vec::with_capacity(self.words.len() * 2);
|
||||
words.extend_from_slice(&self.words);
|
||||
words.extend_from_slice(&self.words);
|
||||
|
||||
Ok(BloomFilter {
|
||||
words,
|
||||
num_bits: self.num_bits * 2,
|
||||
hash_count: self.hash_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Duplicate repeatedly to reach the target size in bits.
|
||||
pub fn duplicate_to(&self, target_bits: usize) -> Result<BloomFilter, BloomError> {
|
||||
if target_bits <= self.num_bits {
|
||||
return Err(BloomError::InvalidTargetSize(target_bits));
|
||||
}
|
||||
if !target_bits.is_power_of_two()
|
||||
|| target_bits > SIZE_CLASS_BYTES[MAX_SIZE_CLASS as usize] * 8
|
||||
{
|
||||
return Err(BloomError::InvalidTargetSize(target_bits));
|
||||
}
|
||||
|
||||
let mut result = self.duplicate()?;
|
||||
while result.num_bits < target_bits {
|
||||
result = result.duplicate()?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Convert the filter to a different size.
|
||||
///
|
||||
/// Folds (if target is smaller) or duplicates (if target is larger).
|
||||
/// Returns a clone if the target matches the current size.
|
||||
pub fn convert_to(&self, target_bits: usize) -> Result<BloomFilter, BloomError> {
|
||||
if target_bits == self.num_bits {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
if target_bits < self.num_bits {
|
||||
self.fold_to(target_bits)
|
||||
} else {
|
||||
self.duplicate_to(target_bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the XOR diff between this filter and another.
|
||||
///
|
||||
/// The result contains only the bits that differ between the two filters.
|
||||
/// Used for delta compression: `old.xor_diff(&new)` produces a diff that
|
||||
/// can be applied to `old` to reconstruct `new`.
|
||||
pub fn xor_diff(&self, other: &BloomFilter) -> Result<BloomFilter, BloomError> {
|
||||
if self.num_bits != other.num_bits {
|
||||
return Err(BloomError::InvalidSize {
|
||||
expected: self.num_bits,
|
||||
got: other.num_bits,
|
||||
});
|
||||
}
|
||||
|
||||
let words: Vec<u64> = self
|
||||
.words
|
||||
.iter()
|
||||
.zip(other.words.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
|
||||
Ok(BloomFilter {
|
||||
words,
|
||||
num_bits: self.num_bits,
|
||||
hash_count: self.hash_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a XOR diff to this filter in place.
|
||||
///
|
||||
/// This is the inverse of `xor_diff()`: if `diff = old.xor_diff(&new)`,
|
||||
/// then `old.apply_diff(&diff)` transforms `old` into `new`.
|
||||
pub fn apply_diff(&mut self, diff: &BloomFilter) -> Result<(), BloomError> {
|
||||
if self.num_bits != diff.num_bits {
|
||||
return Err(BloomError::InvalidSize {
|
||||
expected: self.num_bits,
|
||||
got: diff.num_bits,
|
||||
});
|
||||
}
|
||||
|
||||
for (a, b) in self.words.iter_mut().zip(diff.words.iter()) {
|
||||
*a ^= b;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear all bits in the filter.
|
||||
pub fn clear(&mut self) {
|
||||
self.words.fill(0);
|
||||
}
|
||||
|
||||
/// Count the number of set bits (population count).
|
||||
pub fn count_ones(&self) -> usize {
|
||||
self.words.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
|
||||
/// Estimate the fill ratio (set bits / total bits).
|
||||
pub fn fill_ratio(&self) -> f64 {
|
||||
self.count_ones() as f64 / self.num_bits as f64
|
||||
}
|
||||
|
||||
/// Estimate the number of elements in the filter.
|
||||
///
|
||||
/// Uses the formula: n = -(m/k) * ln(1 - X/m)
|
||||
/// where m = num_bits, k = hash_count, X = count_ones
|
||||
///
|
||||
/// Returns `None` when the filter's FPR exceeds `max_fpr` (antipoison
|
||||
/// cap) or the filter is saturated (`count_ones() >= num_bits`). Pass
|
||||
/// `f64::INFINITY` for `max_fpr` to disable the cap — useful in
|
||||
/// Debug/log contexts where no policy is in scope. The saturated
|
||||
/// branch is always honored regardless of `max_fpr`, preventing the
|
||||
/// `f64::INFINITY` return that the previous signature produced.
|
||||
pub fn estimated_count(&self, max_fpr: f64) -> Option<f64> {
|
||||
let m = self.num_bits as f64;
|
||||
let k = self.hash_count as f64;
|
||||
let x = self.count_ones() as f64;
|
||||
|
||||
if x >= m {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fill = x / m;
|
||||
let fpr = fill.powi(self.hash_count as i32);
|
||||
if fpr > max_fpr {
|
||||
trace!(fill, fpr, max_fpr, "estimated_count: filter above cap");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(-(m / k) * (1.0 - fill).ln())
|
||||
}
|
||||
|
||||
/// Check if the filter is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.words.iter().all(|&w| w == 0)
|
||||
}
|
||||
|
||||
/// Get the filter contents as bytes (little-endian byte order).
|
||||
pub fn as_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(self.words.len() * 8);
|
||||
for &word in &self.words {
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Get the internal word storage.
|
||||
pub fn as_words(&self) -> &[u64] {
|
||||
&self.words
|
||||
}
|
||||
|
||||
/// Get the number of 64-bit words in the filter.
|
||||
pub fn num_words(&self) -> usize {
|
||||
self.words.len()
|
||||
}
|
||||
|
||||
/// Get the filter size in bits.
|
||||
pub fn num_bits(&self) -> usize {
|
||||
self.num_bits
|
||||
}
|
||||
|
||||
/// Get the filter size in bytes.
|
||||
pub fn num_bytes(&self) -> usize {
|
||||
self.words.len() * 8
|
||||
}
|
||||
|
||||
/// Get the number of hash functions.
|
||||
pub fn hash_count(&self) -> u8 {
|
||||
self.hash_count
|
||||
}
|
||||
|
||||
/// Compute a hash index for the given data and hash function number.
|
||||
///
|
||||
/// Uses double hashing: h(x,i) = (h1(x) + i*h2(x)) mod m
|
||||
fn hash(&self, data: &[u8], k: u8) -> usize {
|
||||
// Use first 16 bytes of SHA-256 for h1 and h2
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
// h1 from first 8 bytes
|
||||
let h1 = u64::from_le_bytes(hash[0..8].try_into().unwrap());
|
||||
// h2 from next 8 bytes
|
||||
let h2 = u64::from_le_bytes(hash[8..16].try_into().unwrap());
|
||||
|
||||
let combined = h1.wrapping_add((k as u64).wrapping_mul(h2));
|
||||
(combined as usize) % self.num_bits
|
||||
}
|
||||
|
||||
fn set_bit(&mut self, index: usize) {
|
||||
let word_index = index / 64;
|
||||
let bit_offset = index % 64;
|
||||
self.words[word_index] |= 1 << bit_offset;
|
||||
}
|
||||
|
||||
fn get_bit(&self, index: usize) -> bool {
|
||||
let word_index = index / 64;
|
||||
let bit_offset = index % 64;
|
||||
(self.words[word_index] >> bit_offset) & 1 == 1
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BloomFilter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for BloomFilter {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.num_bits == other.num_bits
|
||||
&& self.hash_count == other.hash_count
|
||||
&& self.words == other.words
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for BloomFilter {}
|
||||
|
||||
impl fmt::Debug for BloomFilter {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("BloomFilter")
|
||||
.field("bits", &self.num_bits)
|
||||
.field("hash_count", &self.hash_count)
|
||||
.field("fill_ratio", &format!("{:.2}%", self.fill_ratio() * 100.0))
|
||||
.field(
|
||||
"est_count",
|
||||
&match self.estimated_count(f64::INFINITY) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "saturated".to_string(),
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//! Bloom Filter Implementation
|
||||
//!
|
||||
//! Variable-size Bloom filters for reachability in FIPS routing. Each
|
||||
//! node maintains filters that summarize which destinations are reachable
|
||||
//! through each peer, enabling efficient routing decisions without
|
||||
//! global network knowledge.
|
||||
//!
|
||||
//! Filter sizes range from 512 bytes (size_class 0) to 32 KB
|
||||
//! (size_class 6), in power-of-two steps. Nodes choose their own
|
||||
//! size class based on subtree load and adapt dynamically.
|
||||
//!
|
||||
//! ## Parameters
|
||||
//!
|
||||
//! - Hash functions: k=5 (network-wide constant)
|
||||
//! - Default size: 1 KB (size_class 1)
|
||||
|
||||
pub mod codec;
|
||||
mod filter;
|
||||
mod state;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub use filter::BloomFilter;
|
||||
pub use state::BloomState;
|
||||
|
||||
/// Default filter size in bits (1KB = 8,192 bits).
|
||||
pub const DEFAULT_FILTER_SIZE_BITS: usize = 8192;
|
||||
|
||||
/// Default filter size in bytes (1KB).
|
||||
pub const DEFAULT_FILTER_SIZE_BYTES: usize = DEFAULT_FILTER_SIZE_BITS / 8;
|
||||
|
||||
/// Default number of hash functions.
|
||||
///
|
||||
/// k=5 is a network-wide constant. Optimal at ~7.2 bits per element.
|
||||
pub const DEFAULT_HASH_COUNT: u8 = 5;
|
||||
|
||||
/// Size class for v1 protocol (1 KB filters).
|
||||
pub const V1_SIZE_CLASS: u8 = 1;
|
||||
|
||||
/// Minimum size class (512 bytes).
|
||||
pub const MIN_SIZE_CLASS: u8 = 0;
|
||||
|
||||
/// Maximum size class (32 KB).
|
||||
pub const MAX_SIZE_CLASS: u8 = 6;
|
||||
|
||||
/// Filter sizes by size_class: bytes = 512 << size_class
|
||||
pub const SIZE_CLASS_BYTES: [usize; 7] = [512, 1024, 2048, 4096, 8192, 16384, 32768];
|
||||
|
||||
/// Convert a size class to filter size in bits.
|
||||
pub fn size_class_to_bits(size_class: u8) -> usize {
|
||||
SIZE_CLASS_BYTES[size_class as usize] * 8
|
||||
}
|
||||
|
||||
/// Convert a filter size in bits to its size class, if valid.
|
||||
pub fn bits_to_size_class(num_bits: usize) -> Option<u8> {
|
||||
let num_bytes = num_bits / 8;
|
||||
SIZE_CLASS_BYTES
|
||||
.iter()
|
||||
.position(|&s| s == num_bytes)
|
||||
.map(|i| i as u8)
|
||||
}
|
||||
|
||||
/// Errors related to Bloom filter operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BloomError {
|
||||
#[error("invalid filter size: expected {expected} bits, got {got}")]
|
||||
InvalidSize { expected: usize, got: usize },
|
||||
|
||||
#[error("filter size must be a multiple of 8, got {0}")]
|
||||
SizeNotByteAligned(usize),
|
||||
|
||||
#[error("filter size must be a multiple of 64, got {0}")]
|
||||
SizeNotWordAligned(usize),
|
||||
|
||||
#[error("hash count must be positive")]
|
||||
ZeroHashCount,
|
||||
|
||||
#[error("cannot fold: filter is already at minimum size ({0} bits)")]
|
||||
CannotFold(usize),
|
||||
|
||||
#[error("cannot duplicate: filter is already at maximum size ({0} bits)")]
|
||||
CannotDuplicate(usize),
|
||||
|
||||
#[error("target size {0} bits is not a valid power-of-two filter size")]
|
||||
InvalidTargetSize(usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
-1064
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -9,7 +9,6 @@
|
||||
// distance to extracting the pure cores into a `no_std` crate later.
|
||||
extern crate alloc;
|
||||
|
||||
pub mod bloom;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
@@ -47,8 +46,8 @@ pub use discovery::{BootstrapHandoffResult, EstablishedTraversal};
|
||||
// Re-export tree types (relocated from tree:: to proto::stp)
|
||||
pub use proto::stp::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeState};
|
||||
|
||||
// Re-export bloom filter types
|
||||
pub use bloom::{BloomError, BloomFilter, BloomState};
|
||||
// Re-export bloom filter types (relocated from bloom:: to proto::bloom)
|
||||
pub use proto::bloom::{BloomError, BloomFilter, BloomState};
|
||||
|
||||
// Re-export transport types
|
||||
pub use transport::udp::UdpTransport;
|
||||
@@ -60,13 +59,16 @@ pub use transport::{
|
||||
|
||||
// Re-export protocol types
|
||||
pub use protocol::{
|
||||
FilterAnnounce, LinkMessageType, ProtocolError, SessionAck, SessionDatagram, SessionFlags,
|
||||
SessionMessageType, SessionSetup,
|
||||
LinkMessageType, ProtocolError, SessionAck, SessionDatagram, SessionFlags, SessionMessageType,
|
||||
SessionSetup,
|
||||
};
|
||||
|
||||
// Re-export STP wire types (relocated from protocol:: to proto::stp)
|
||||
pub use proto::stp::TreeAnnounce;
|
||||
|
||||
// Re-export bloom wire types (relocated from protocol:: to proto::bloom)
|
||||
pub use proto::bloom::FilterAnnounce;
|
||||
|
||||
// Re-export discovery wire types (relocated from protocol:: to proto::discovery)
|
||||
pub use proto::discovery::{LookupRequest, LookupResponse};
|
||||
|
||||
|
||||
+45
-217
@@ -1,30 +1,26 @@
|
||||
//! Bloom filter announce send/receive logic.
|
||||
//!
|
||||
//! Handles building, sending, and receiving FilterAnnounce messages,
|
||||
//! including delta compression with NACK-based recovery and debounced
|
||||
//! propagation to peers.
|
||||
//! including debounced propagation to peers.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::protocol::{FilterAnnounce, FilterNack};
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::bloom::FilterAnnounce;
|
||||
|
||||
use super::reject::BloomReject;
|
||||
use super::{Node, NodeError};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, trace, warn};
|
||||
use std::collections::BTreeMap;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
impl Node {
|
||||
/// Collect inbound filters from full tree peers for outgoing filter computation.
|
||||
/// Collect inbound filters from all peers for outgoing filter computation.
|
||||
///
|
||||
/// Returns a map of (peer_node_addr -> filter) for peers that
|
||||
/// have sent us a FilterAnnounce. Non-routing and leaf peers are
|
||||
/// excluded (they don't send filters; their identity is covered
|
||||
/// via leaf_dependents).
|
||||
pub(super) fn peer_inbound_filters(&self) -> HashMap<NodeAddr, BloomFilter> {
|
||||
let mut filters = HashMap::new();
|
||||
/// have sent us a FilterAnnounce.
|
||||
pub(super) fn peer_inbound_filters(&self) -> BTreeMap<NodeAddr, BloomFilter> {
|
||||
let mut filters = BTreeMap::new();
|
||||
for (addr, peer) in &self.peers {
|
||||
if self.is_tree_peer(addr)
|
||||
&& peer.peer_profile() == crate::proto::fmp::NodeProfile::Full
|
||||
&& let Some(filter) = peer.inbound_filter()
|
||||
{
|
||||
filters.insert(*addr, filter.clone());
|
||||
@@ -35,29 +31,16 @@ impl Node {
|
||||
|
||||
/// Build a FilterAnnounce for a specific peer.
|
||||
///
|
||||
/// Returns a delta (XOR diff) if we have a previous filter for this peer
|
||||
/// at the same size class. Otherwise returns a full send.
|
||||
/// The outgoing filter excludes the destination peer's own filter
|
||||
/// to prevent routing loops (don't tell a peer about destinations
|
||||
/// reachable only through them).
|
||||
fn build_filter_announce(&mut self, exclude_peer: &NodeAddr) -> FilterAnnounce {
|
||||
let peer_filters = self.peer_inbound_filters();
|
||||
let filter = self
|
||||
.bloom_state
|
||||
.compute_outgoing_filter(exclude_peer, &peer_filters);
|
||||
let sequence = self.bloom_state.next_sequence();
|
||||
let size_class = self.bloom_state.size_class();
|
||||
|
||||
// Try delta if we have a previous filter for this peer at the same size
|
||||
if let Some(last_filter) = self.bloom_state.last_sent_filter(exclude_peer)
|
||||
&& last_filter.num_bits() == filter.num_bits()
|
||||
&& let (Some(base_seq), Ok(diff)) = (
|
||||
self.bloom_state.last_sent_seq(exclude_peer),
|
||||
last_filter.xor_diff(&filter),
|
||||
)
|
||||
{
|
||||
return FilterAnnounce::delta(diff, sequence, base_seq, size_class);
|
||||
}
|
||||
|
||||
// Full send
|
||||
FilterAnnounce::full(filter, sequence, size_class)
|
||||
FilterAnnounce::new(filter, sequence)
|
||||
}
|
||||
|
||||
/// Send a FilterAnnounce to a specific peer, respecting debounce.
|
||||
@@ -82,22 +65,8 @@ impl Node {
|
||||
|
||||
// Build and encode
|
||||
let announce = self.build_filter_announce(peer_addr);
|
||||
let is_delta = announce.is_delta;
|
||||
let sent_filter = if is_delta {
|
||||
// For deltas, reconstruct the actual filter for change detection:
|
||||
// apply the diff to the last-sent filter
|
||||
let mut reconstructed = self
|
||||
.bloom_state
|
||||
.last_sent_filter(peer_addr)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let _ = reconstructed.apply_diff(&announce.filter);
|
||||
reconstructed
|
||||
} else {
|
||||
announce.filter.clone()
|
||||
};
|
||||
|
||||
let (encoded, stats) = announce.encode().map_err(|e| NodeError::SendFailed {
|
||||
let sent_filter = announce.filter.clone();
|
||||
let encoded = announce.encode().map_err(|e| NodeError::SendFailed {
|
||||
node_addr: *peer_addr,
|
||||
reason: format!("FilterAnnounce encode failed: {}", e),
|
||||
})?;
|
||||
@@ -109,19 +78,6 @@ impl Node {
|
||||
}
|
||||
|
||||
self.metrics().bloom.sent.inc();
|
||||
if is_delta {
|
||||
self.metrics().bloom.deltas_sent.inc();
|
||||
} else {
|
||||
self.metrics().bloom.full_sends.inc();
|
||||
}
|
||||
self.metrics()
|
||||
.bloom
|
||||
.total_compressed_bytes
|
||||
.add(stats.compressed_bytes as u64);
|
||||
self.metrics()
|
||||
.bloom
|
||||
.total_raw_bytes
|
||||
.add((stats.raw_words * 8) as u64);
|
||||
|
||||
// Self-plausibility check: WARN if our own outgoing filter is
|
||||
// above the antipoison cap. Independent detection signal if
|
||||
@@ -154,15 +110,13 @@ impl Node {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
seq = announce.sequence,
|
||||
delta = is_delta,
|
||||
compressed = stats.compressed_bytes,
|
||||
runs = stats.run_count,
|
||||
est_entries = match sent_filter.estimated_count(max_fpr) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "—".to_string(),
|
||||
},
|
||||
set_bits = sent_filter.count_ones(),
|
||||
fill = format_args!("{:.1}%", sent_filter.fill_ratio() * 100.0),
|
||||
tree_peer = self.is_tree_peer(peer_addr),
|
||||
"Sent FilterAnnounce"
|
||||
);
|
||||
self.bloom_state.record_update_sent(*peer_addr, now_ms);
|
||||
@@ -175,14 +129,7 @@ impl Node {
|
||||
}
|
||||
|
||||
/// Send pending rate-limited filter announces whose debounce has expired.
|
||||
///
|
||||
/// Non-routing nodes do not send filters (they receive only).
|
||||
pub(super) async fn send_pending_filter_announces(&mut self) {
|
||||
// Non-routing and leaf nodes don't send bloom filters
|
||||
if self.node_profile() != crate::proto::fmp::NodeProfile::Full {
|
||||
return;
|
||||
}
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
@@ -208,8 +155,10 @@ impl Node {
|
||||
|
||||
/// Handle an inbound FilterAnnounce from an authenticated peer.
|
||||
///
|
||||
/// Supports both full sends and delta (XOR diff) updates.
|
||||
/// On out-of-sequence delta, sends a NACK to request full retransmission.
|
||||
/// 1. Decode and validate the message
|
||||
/// 2. Check sequence freshness (reject stale/replay)
|
||||
/// 3. Store the filter on the peer
|
||||
/// 4. Mark other peers for outgoing filter update
|
||||
pub(super) async fn handle_filter_announce(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
self.metrics().bloom.received.inc();
|
||||
|
||||
@@ -228,22 +177,26 @@ impl Node {
|
||||
debug!(from = %self.peer_display_name(from), "FilterAnnounce filter/size_class mismatch");
|
||||
return;
|
||||
}
|
||||
if !announce.is_v1_compliant() {
|
||||
self.metrics().bloom.record_reject(BloomReject::NonV1);
|
||||
debug!(from = %self.peer_display_name(from), size_class = announce.size_class, "Non-v1 FilterAnnounce rejected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check peer exists
|
||||
let peer = match self.peers.get(from) {
|
||||
Some(p) => p,
|
||||
let current_seq = match self.peers.get(from) {
|
||||
Some(peer) => peer.filter_sequence(),
|
||||
None => {
|
||||
self.metrics().bloom.record_reject(BloomReject::UnknownPeer);
|
||||
debug!(from = %self.peer_display_name(from), "FilterAnnounce from unknown peer");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let current_seq = peer.filter_sequence();
|
||||
|
||||
// Reject stale/replay
|
||||
if announce.sequence <= current_seq {
|
||||
self.metrics().bloom.record_reject(BloomReject::Stale);
|
||||
trace!(
|
||||
debug!(
|
||||
from = %self.peer_display_name(from),
|
||||
received_seq = announce.sequence,
|
||||
current_seq = current_seq,
|
||||
@@ -252,75 +205,15 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle delta vs full
|
||||
let resolved_filter = if announce.is_delta {
|
||||
// Delta: apply XOR diff to stored inbound filter
|
||||
let expected_base = current_seq;
|
||||
if announce.base_seq != expected_base {
|
||||
// Out-of-sequence delta — send NACK
|
||||
debug!(
|
||||
from = %self.peer_display_name(from),
|
||||
expected_base = expected_base,
|
||||
got_base = announce.base_seq,
|
||||
"Out-of-sequence delta, sending NACK"
|
||||
);
|
||||
let nack = FilterNack {
|
||||
expected_seq: expected_base,
|
||||
};
|
||||
let nack_encoded = nack.encode();
|
||||
let _ = self.send_encrypted_link_message(from, &nack_encoded).await;
|
||||
self.metrics().bloom.nacks_sent.inc();
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply diff to current inbound filter
|
||||
match self.peers.get(from).and_then(|p| p.inbound_filter()) {
|
||||
Some(current) => {
|
||||
let mut result = current.clone();
|
||||
if let Err(e) = result.apply_diff(&announce.filter) {
|
||||
warn!(
|
||||
from = %self.peer_display_name(from),
|
||||
error = %e,
|
||||
"Failed to apply filter delta"
|
||||
);
|
||||
// Send NACK to request full retransmit
|
||||
let nack = FilterNack {
|
||||
expected_seq: current_seq,
|
||||
};
|
||||
let _ = self.send_encrypted_link_message(from, &nack.encode()).await;
|
||||
self.metrics().bloom.nacks_sent.inc();
|
||||
return;
|
||||
}
|
||||
result
|
||||
}
|
||||
None => {
|
||||
// No stored filter to apply delta to — NACK
|
||||
debug!(
|
||||
from = %self.peer_display_name(from),
|
||||
"Delta received but no stored filter, sending NACK"
|
||||
);
|
||||
let nack = FilterNack { expected_seq: 0 };
|
||||
let _ = self.send_encrypted_link_message(from, &nack.encode()).await;
|
||||
self.metrics().bloom.nacks_sent.inc();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full send: use directly
|
||||
announce.filter.clone()
|
||||
};
|
||||
|
||||
// Antipoison FPR cap. Reject announces whose resolved filter FPR
|
||||
// exceeds node.bloom.max_inbound_fpr. Silent on the wire (no NACK)
|
||||
// — the peer's prior accepted filter and filter_sequence stay
|
||||
// untouched so the peer is not permanently silenced and a single
|
||||
// corrupted frame cannot be weaponized to wipe a victim's
|
||||
// contribution to aggregation. Applied to the resolved filter so
|
||||
// deltas that reconstruct to an over-cap state are caught as well
|
||||
// as full sends.
|
||||
// Antipoison FPR cap. Reject announces whose FPR exceeds
|
||||
// node.bloom.max_inbound_fpr. Silent on the wire (no NACK) —
|
||||
// the peer's prior accepted filter and filter_sequence stay
|
||||
// untouched so the peer is not permanently silenced and an
|
||||
// on-path attacker cannot weaponize a single corrupted frame
|
||||
// to wipe a victim's contribution to aggregation.
|
||||
let max_fpr = self.config().node.bloom.max_inbound_fpr;
|
||||
let fill = resolved_filter.fill_ratio();
|
||||
let fpr = fill.powi(resolved_filter.hash_count() as i32);
|
||||
let fill = announce.filter.fill_ratio();
|
||||
let fpr = fill.powi(announce.filter.hash_count() as i32);
|
||||
if fpr > max_fpr {
|
||||
self.metrics()
|
||||
.bloom
|
||||
@@ -346,99 +239,34 @@ impl Node {
|
||||
debug!(
|
||||
from = %self.peer_display_name(from),
|
||||
seq = announce.sequence,
|
||||
delta = announce.is_delta,
|
||||
est_entries = match resolved_filter.estimated_count(max_fpr) {
|
||||
est_entries = match announce.filter.estimated_count(max_fpr) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "—".to_string(),
|
||||
},
|
||||
set_bits = resolved_filter.count_ones(),
|
||||
fill = format_args!("{:.1}%", resolved_filter.fill_ratio() * 100.0),
|
||||
set_bits = announce.filter.count_ones(),
|
||||
fill = format_args!("{:.1}%", announce.filter.fill_ratio() * 100.0),
|
||||
tree_peer = self.is_tree_peer(from),
|
||||
"Received FilterAnnounce"
|
||||
);
|
||||
|
||||
// Store resolved filter on peer
|
||||
// Store on peer
|
||||
if let Some(peer) = self.peers.get_mut(from) {
|
||||
peer.update_filter(resolved_filter, announce.sequence, now_ms);
|
||||
peer.update_filter(announce.filter, announce.sequence, now_ms);
|
||||
}
|
||||
|
||||
// Check which peers' outgoing filters actually changed
|
||||
// Check which peers' outgoing filters actually changed.
|
||||
// All peers receive filters, but only tree peers' inbound filters
|
||||
// are merged into outgoing computation (tree-only propagation).
|
||||
let peer_addrs: Vec<NodeAddr> = self.peers.keys().copied().collect();
|
||||
let peer_filters = self.peer_inbound_filters();
|
||||
self.bloom_state
|
||||
.mark_changed_peers(from, &peer_addrs, &peer_filters);
|
||||
}
|
||||
|
||||
/// Handle an inbound FilterNack from a peer.
|
||||
///
|
||||
/// Clears the last-sent filter for that peer, forcing a full re-send
|
||||
/// on the next tick.
|
||||
pub(super) async fn handle_filter_nack(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
let nack = match FilterNack::decode(payload) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed FilterNack");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
from = %self.peer_display_name(from),
|
||||
expected_seq = nack.expected_seq,
|
||||
"Received FilterNack, scheduling full re-send"
|
||||
);
|
||||
|
||||
self.metrics().bloom.nacks_received.inc();
|
||||
// Clear sent state for this peer → next send will be full
|
||||
self.bloom_state.clear_sent_filter(from);
|
||||
self.bloom_state.mark_update_needed(*from);
|
||||
}
|
||||
|
||||
/// Evaluate adaptive filter sizing and adjust if needed.
|
||||
///
|
||||
/// Checks the outgoing fill ratio for a representative peer and
|
||||
/// steps up or down the size class if thresholds are crossed.
|
||||
/// On size change, clears all sent filters (forcing full re-sends)
|
||||
/// and marks all peers for update.
|
||||
fn check_adaptive_sizing(&mut self) {
|
||||
// Only Full nodes participate in filter sizing
|
||||
if self.node_profile() != crate::proto::fmp::NodeProfile::Full {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use an arbitrary peer to compute a representative outgoing filter
|
||||
let representative_peer = match self.peers.keys().next() {
|
||||
Some(addr) => *addr,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let peer_filters = self.peer_inbound_filters();
|
||||
let outgoing = self
|
||||
.bloom_state
|
||||
.compute_outgoing_filter(&representative_peer, &peer_filters);
|
||||
let fill = outgoing.fill_ratio();
|
||||
|
||||
if let Some(new_class) = self.bloom_state.evaluate_size_change(fill) {
|
||||
let old_class = self.bloom_state.size_class();
|
||||
debug!(
|
||||
old_class = old_class,
|
||||
new_class = new_class,
|
||||
fill = format_args!("{:.1}%", fill * 100.0),
|
||||
"Adaptive bloom filter resize"
|
||||
);
|
||||
self.bloom_state.set_size_class(new_class);
|
||||
self.bloom_state.clear_all_sent_filters();
|
||||
self.metrics().bloom.size_changes.inc();
|
||||
let all_peers: Vec<NodeAddr> = self.peers.keys().copied().collect();
|
||||
self.bloom_state.mark_all_updates_needed(all_peers);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check bloom filter state on tick (called from event loop).
|
||||
///
|
||||
/// Evaluates adaptive sizing, then sends any pending filter announces.
|
||||
/// Sends any pending debounced filter announces.
|
||||
pub(super) async fn check_bloom_state(&mut self) {
|
||||
self.check_adaptive_sizing();
|
||||
self.send_pending_filter_announces().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,6 @@ impl Node {
|
||||
// FilterAnnounce
|
||||
self.handle_filter_announce(from, payload).await;
|
||||
}
|
||||
0x21 => {
|
||||
// FilterNack
|
||||
self.handle_filter_nack(from, payload).await;
|
||||
}
|
||||
0x30 => {
|
||||
// LookupRequest
|
||||
self.handle_lookup_request(from, payload).await;
|
||||
|
||||
+1
-1
@@ -53,10 +53,10 @@ use self::wire::{
|
||||
ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, build_encrypted, build_established_header,
|
||||
prepend_inner_header,
|
||||
};
|
||||
use crate::bloom::{BloomFilter, BloomState};
|
||||
use crate::cache::CoordCache;
|
||||
use crate::node::session::SessionEntry;
|
||||
use crate::peer::{ActivePeer, PeerConnection};
|
||||
use crate::proto::bloom::{BloomFilter, BloomState};
|
||||
use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter};
|
||||
use crate::proto::fmp::Fmp;
|
||||
use crate::proto::fmp::NodeProfile;
|
||||
|
||||
@@ -455,8 +455,8 @@ async fn test_bloom_filter_split_horizon() {
|
||||
/// counted once, not the double-count fingerprint.
|
||||
#[test]
|
||||
fn compute_mesh_size_counts_each_peer_filter_once() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::stp::ParentDeclaration;
|
||||
|
||||
let mut node = make_node();
|
||||
@@ -550,8 +550,8 @@ fn compute_mesh_size_counts_each_peer_filter_once() {
|
||||
/// `estimated_mesh_size` carries.
|
||||
#[test]
|
||||
fn compute_mesh_size_unions_overlapping_filters() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
@@ -632,8 +632,8 @@ fn compute_mesh_size_unions_overlapping_filters() {
|
||||
/// removes the parent, and asserts the estimate does not collapse.
|
||||
#[test]
|
||||
fn compute_mesh_size_stable_across_parent_drop_with_cross_link() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
//! Registry-counter coverage tests for the bloom-v2 metric counters that
|
||||
//! the mesh-lab suites do not reliably exercise.
|
||||
//!
|
||||
//! The send-path bloom counters (`deltas_sent`, `full_sends`,
|
||||
//! `total_compressed_bytes`, `total_raw_bytes`) fire on every filter
|
||||
//! announce and are covered by the steady-state suites. The three
|
||||
//! condition-dependent counters (`nacks_sent`, `nacks_received`,
|
||||
//! `size_changes`) only fire on out-of-sequence deltas, inbound NACKs,
|
||||
//! and adaptive resizes — none of which occur in the stable, lossless
|
||||
//! mesh-lab scenarios. These tests drive each of those paths directly and
|
||||
//! assert the registry counter increments.
|
||||
|
||||
use super::*;
|
||||
use crate::bloom::{BloomFilter, V1_SIZE_CLASS};
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::protocol::{FilterAnnounce, FilterNack};
|
||||
|
||||
/// Inject a synthetic active peer with a known NodeAddr; returns it.
|
||||
fn inject_peer(node: &mut Node) -> NodeAddr {
|
||||
let peer_identity = make_peer_identity();
|
||||
let peer_addr = *peer_identity.node_addr();
|
||||
let peer = ActivePeer::new(peer_identity, LinkId::new(1), 0);
|
||||
node.peers.insert(peer_addr, peer);
|
||||
peer_addr
|
||||
}
|
||||
|
||||
/// Encode a FilterAnnounce to the payload format handle_filter_announce
|
||||
/// expects (msg_type byte stripped).
|
||||
fn encode_announce(announce: &FilterAnnounce) -> Vec<u8> {
|
||||
let (mut full, _stats) = announce.encode().unwrap();
|
||||
full.remove(0); // strip msg_type byte
|
||||
full
|
||||
}
|
||||
|
||||
/// An out-of-sequence delta to a peer with no stored filter makes the node
|
||||
/// send a NACK, bumping `nacks_sent`.
|
||||
#[tokio::test]
|
||||
async fn test_bloom_nacks_sent_counter() {
|
||||
let mut node = make_node();
|
||||
let peer_addr = inject_peer(&mut node);
|
||||
|
||||
// Fresh peer: filter_sequence == 0. A delta whose base_seq does not
|
||||
// match the expected base (0) is out-of-sequence → NACK.
|
||||
let announce = FilterAnnounce::delta(BloomFilter::new(), 2, 5, V1_SIZE_CLASS);
|
||||
let payload = encode_announce(&announce);
|
||||
|
||||
node.handle_filter_announce(&peer_addr, &payload).await;
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().bloom.nacks_sent.get(),
|
||||
1,
|
||||
"registry nacks_sent must increment on out-of-sequence delta"
|
||||
);
|
||||
}
|
||||
|
||||
/// An inbound FilterNack bumps `nacks_received`.
|
||||
#[tokio::test]
|
||||
async fn test_bloom_nacks_received_counter() {
|
||||
let mut node = make_node();
|
||||
let peer_addr = inject_peer(&mut node);
|
||||
|
||||
let mut payload = FilterNack { expected_seq: 7 }.encode();
|
||||
payload.remove(0); // strip msg_type byte (decode expects the seq only)
|
||||
|
||||
node.handle_filter_nack(&peer_addr, &payload).await;
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().bloom.nacks_received.get(),
|
||||
1,
|
||||
"registry nacks_received must increment on inbound NACK"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fresh Full node starts at V1_SIZE_CLASS with a nearly empty outgoing
|
||||
/// filter (just its own addr), so the first adaptive-sizing pass steps the
|
||||
/// size class down, bumping `size_changes`.
|
||||
#[tokio::test]
|
||||
async fn test_bloom_size_changes_counter() {
|
||||
let mut node = make_node();
|
||||
// check_adaptive_sizing needs at least one peer for the representative
|
||||
// outgoing-filter computation.
|
||||
let _peer = inject_peer(&mut node);
|
||||
|
||||
assert_eq!(
|
||||
node.bloom_state.size_class(),
|
||||
V1_SIZE_CLASS,
|
||||
"fresh node starts at the v1 size class"
|
||||
);
|
||||
|
||||
node.check_bloom_state().await;
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().bloom.size_changes.get(),
|
||||
1,
|
||||
"registry size_changes must increment on adaptive resize"
|
||||
);
|
||||
assert_eq!(
|
||||
node.bloom_state.size_class(),
|
||||
V1_SIZE_CLASS - 1,
|
||||
"near-empty outgoing filter steps the size class down"
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,9 @@
|
||||
//! bloom.rs.
|
||||
|
||||
use super::*;
|
||||
use crate::bloom::{BloomFilter, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::protocol::FilterAnnounce;
|
||||
use crate::proto::bloom::FilterAnnounce;
|
||||
use crate::proto::bloom::{BloomFilter, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
|
||||
/// Inject a synthetic active peer into the node with a known NodeAddr.
|
||||
/// Returns the peer's NodeAddr.
|
||||
@@ -24,7 +24,7 @@ fn inject_peer(node: &mut Node) -> NodeAddr {
|
||||
/// Encode a FilterAnnounce to the payload format handle_filter_announce
|
||||
/// expects (msg_type byte stripped).
|
||||
fn encode_payload(announce: &FilterAnnounce) -> Vec<u8> {
|
||||
let (mut full, _stats) = announce.encode().unwrap();
|
||||
let mut full = announce.encode().unwrap();
|
||||
full.remove(0); // strip msg_type byte
|
||||
full
|
||||
}
|
||||
@@ -40,7 +40,7 @@ async fn test_m1_rejects_all_ones_filter_announce() {
|
||||
DEFAULT_HASH_COUNT,
|
||||
)
|
||||
.unwrap();
|
||||
let announce = FilterAnnounce::full(all_ones, 1, 1);
|
||||
let announce = FilterAnnounce::new(all_ones, 1);
|
||||
let payload = encode_payload(&announce);
|
||||
|
||||
let before_fill_exceeded = node.metrics().bloom.fill_exceeded.get();
|
||||
@@ -88,7 +88,7 @@ async fn test_m1_accepts_sub_cap_filter() {
|
||||
bytes[0] = i;
|
||||
filter.insert(&NodeAddr::from_bytes(bytes));
|
||||
}
|
||||
let announce = FilterAnnounce::full(filter, 1, 1);
|
||||
let announce = FilterAnnounce::new(filter, 1);
|
||||
let payload = encode_payload(&announce);
|
||||
|
||||
let before_fill_exceeded = node.metrics().bloom.fill_exceeded.get();
|
||||
@@ -135,7 +135,7 @@ async fn test_m1_sequence_not_advanced_allows_recovery() {
|
||||
DEFAULT_HASH_COUNT,
|
||||
)
|
||||
.unwrap();
|
||||
let bad_announce = FilterAnnounce::full(bad, 1, 1);
|
||||
let bad_announce = FilterAnnounce::new(bad, 1);
|
||||
node.handle_filter_announce(&peer_addr, &encode_payload(&bad_announce))
|
||||
.await;
|
||||
assert_eq!(
|
||||
@@ -152,7 +152,7 @@ async fn test_m1_sequence_not_advanced_allows_recovery() {
|
||||
bytes[0] = i;
|
||||
good.insert(&NodeAddr::from_bytes(bytes));
|
||||
}
|
||||
let good_announce = FilterAnnounce::full(good, 1, 1);
|
||||
let good_announce = FilterAnnounce::new(good, 1);
|
||||
node.handle_filter_announce(&peer_addr, &encode_payload(&good_announce))
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1160,8 +1160,8 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
|
||||
/// that `initiate_lookup` ran fresh on each attempt.
|
||||
#[tokio::test]
|
||||
async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::discovery::PendingLookup;
|
||||
use crate::transport::LinkId;
|
||||
use std::sync::mpsc;
|
||||
|
||||
@@ -8,7 +8,6 @@ mod acl;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ble;
|
||||
mod bloom;
|
||||
mod bloom_metrics;
|
||||
mod bloom_poison;
|
||||
mod bootstrap;
|
||||
mod decrypt_failure;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! filter priority, greedy tree routing, and tie-breaking.
|
||||
|
||||
use super::*;
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::stp::{ParentDeclaration, TreeCoordinate};
|
||||
use spanning_tree::{
|
||||
TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake,
|
||||
|
||||
+1
-1
@@ -3,10 +3,10 @@
|
||||
//! Represents a fully authenticated peer after successful Noise handshake.
|
||||
//! ActivePeer holds tree state, Bloom filter, and routing information.
|
||||
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::mmp::MmpConfig;
|
||||
use crate::node::REKEY_JITTER_SECS;
|
||||
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::fmp::{NegotiationPayload, NodeProfile};
|
||||
use crate::proto::mmp::MmpPeerState;
|
||||
use crate::proto::stp::{ParentDeclaration, TreeCoordinate};
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Generic Bloom filter data structure.
|
||||
|
||||
use core::fmt;
|
||||
|
||||
use super::{BloomError, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
use crate::NodeAddr;
|
||||
|
||||
/// A Bloom filter for probabilistic set membership.
|
||||
///
|
||||
/// Used in FIPS to track which destinations are reachable through a peer.
|
||||
/// The filter uses double hashing to generate k hash functions from two
|
||||
/// base hashes derived from the input.
|
||||
#[derive(Clone)]
|
||||
pub struct BloomFilter {
|
||||
/// Bit array storage (packed as bytes).
|
||||
bits: Vec<u8>,
|
||||
/// Number of bits in the filter.
|
||||
num_bits: usize,
|
||||
/// Number of hash functions to use.
|
||||
hash_count: u8,
|
||||
}
|
||||
|
||||
impl BloomFilter {
|
||||
/// Create a new empty Bloom filter with default parameters.
|
||||
pub fn new() -> Self {
|
||||
Self::with_params(DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT)
|
||||
.expect("default params are valid")
|
||||
}
|
||||
|
||||
/// Create a Bloom filter with custom parameters.
|
||||
pub fn with_params(num_bits: usize, hash_count: u8) -> Result<Self, BloomError> {
|
||||
if num_bits == 0 || !num_bits.is_multiple_of(8) {
|
||||
return Err(BloomError::SizeNotByteAligned(num_bits));
|
||||
}
|
||||
if hash_count == 0 {
|
||||
return Err(BloomError::ZeroHashCount);
|
||||
}
|
||||
|
||||
let num_bytes = num_bits / 8;
|
||||
Ok(Self {
|
||||
bits: vec![0u8; num_bytes],
|
||||
num_bits,
|
||||
hash_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a Bloom filter from raw bytes.
|
||||
pub fn from_bytes(bytes: Vec<u8>, hash_count: u8) -> Result<Self, BloomError> {
|
||||
if hash_count == 0 {
|
||||
return Err(BloomError::ZeroHashCount);
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Err(BloomError::SizeNotByteAligned(0));
|
||||
}
|
||||
let num_bits = bytes.len() * 8;
|
||||
Ok(Self {
|
||||
bits: bytes,
|
||||
num_bits,
|
||||
hash_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a Bloom filter from a byte slice.
|
||||
pub fn from_slice(bytes: &[u8], hash_count: u8) -> Result<Self, BloomError> {
|
||||
Self::from_bytes(bytes.to_vec(), hash_count)
|
||||
}
|
||||
|
||||
/// Insert a NodeAddr into the filter.
|
||||
pub fn insert(&mut self, node_addr: &NodeAddr) {
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.hash(node_addr.as_bytes(), i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert raw bytes into the filter.
|
||||
pub fn insert_bytes(&mut self, data: &[u8]) {
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.hash(data, i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the filter might contain a NodeAddr.
|
||||
///
|
||||
/// Returns `true` if the item might be in the set (possible false positive).
|
||||
/// Returns `false` if the item is definitely not in the set.
|
||||
pub fn contains(&self, node_addr: &NodeAddr) -> bool {
|
||||
self.contains_bytes(node_addr.as_bytes())
|
||||
}
|
||||
|
||||
/// Check if the filter might contain raw bytes.
|
||||
pub fn contains_bytes(&self, data: &[u8]) -> bool {
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.hash(data, i);
|
||||
if !self.get_bit(bit_index) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Merge another filter into this one (OR operation).
|
||||
///
|
||||
/// After merge, this filter contains all elements from both filters.
|
||||
pub fn merge(&mut self, other: &BloomFilter) -> Result<(), BloomError> {
|
||||
if self.num_bits != other.num_bits {
|
||||
return Err(BloomError::InvalidSize {
|
||||
expected: self.num_bits,
|
||||
got: other.num_bits,
|
||||
});
|
||||
}
|
||||
|
||||
for (a, b) in self.bits.iter_mut().zip(other.bits.iter()) {
|
||||
*a |= b;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new filter that is the union of this and another.
|
||||
pub fn union(&self, other: &BloomFilter) -> Result<Self, BloomError> {
|
||||
let mut result = self.clone();
|
||||
result.merge(other)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Clear all bits in the filter.
|
||||
pub fn clear(&mut self) {
|
||||
self.bits.fill(0);
|
||||
}
|
||||
|
||||
/// Count the number of set bits (population count).
|
||||
pub fn count_ones(&self) -> usize {
|
||||
self.bits.iter().map(|b| b.count_ones() as usize).sum()
|
||||
}
|
||||
|
||||
/// Estimate the fill ratio (set bits / total bits).
|
||||
pub fn fill_ratio(&self) -> f64 {
|
||||
self.count_ones() as f64 / self.num_bits as f64
|
||||
}
|
||||
|
||||
/// Estimate the number of elements in the filter.
|
||||
///
|
||||
/// Uses the formula: n = -(m/k) * ln(1 - X/m)
|
||||
/// where m = num_bits, k = hash_count, X = count_ones
|
||||
///
|
||||
/// Returns `None` when the filter's FPR exceeds `max_fpr` (antipoison
|
||||
/// cap) or the filter is saturated (`count_ones() >= num_bits`). Pass
|
||||
/// `f64::INFINITY` for `max_fpr` to disable the cap — useful in
|
||||
/// Debug/log contexts where no policy is in scope. The saturated
|
||||
/// branch is always honored regardless of `max_fpr`, preventing the
|
||||
/// `f64::INFINITY` return that the previous signature produced.
|
||||
pub fn estimated_count(&self, max_fpr: f64) -> Option<f64> {
|
||||
let m = self.num_bits as f64;
|
||||
let k = self.hash_count as f64;
|
||||
let x = self.count_ones() as f64;
|
||||
|
||||
if x >= m {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fill = x / m;
|
||||
let fpr = fill.powi(self.hash_count as i32);
|
||||
if fpr > max_fpr {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(-(m / k) * (1.0 - fill).ln())
|
||||
}
|
||||
|
||||
/// Check if the filter is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bits.iter().all(|&b| b == 0)
|
||||
}
|
||||
|
||||
/// Get the raw bytes.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.bits
|
||||
}
|
||||
|
||||
/// Get the filter size in bits.
|
||||
pub fn num_bits(&self) -> usize {
|
||||
self.num_bits
|
||||
}
|
||||
|
||||
/// Get the filter size in bytes.
|
||||
pub fn num_bytes(&self) -> usize {
|
||||
self.bits.len()
|
||||
}
|
||||
|
||||
/// Get the number of hash functions.
|
||||
pub fn hash_count(&self) -> u8 {
|
||||
self.hash_count
|
||||
}
|
||||
|
||||
/// Compute a hash index for the given data and hash function number.
|
||||
///
|
||||
/// Uses double hashing: h(x,i) = (h1(x) + i*h2(x)) mod m
|
||||
fn hash(&self, data: &[u8], k: u8) -> usize {
|
||||
// Use first 16 bytes of SHA-256 for h1 and h2
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
// h1 from first 8 bytes
|
||||
let h1 = u64::from_le_bytes(hash[0..8].try_into().unwrap());
|
||||
// h2 from next 8 bytes
|
||||
let h2 = u64::from_le_bytes(hash[8..16].try_into().unwrap());
|
||||
|
||||
let combined = h1.wrapping_add((k as u64).wrapping_mul(h2));
|
||||
(combined as usize) % self.num_bits
|
||||
}
|
||||
|
||||
fn set_bit(&mut self, index: usize) {
|
||||
let byte_index = index / 8;
|
||||
let bit_offset = index % 8;
|
||||
self.bits[byte_index] |= 1 << bit_offset;
|
||||
}
|
||||
|
||||
fn get_bit(&self, index: usize) -> bool {
|
||||
let byte_index = index / 8;
|
||||
let bit_offset = index % 8;
|
||||
(self.bits[byte_index] >> bit_offset) & 1 == 1
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BloomFilter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for BloomFilter {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.num_bits == other.num_bits
|
||||
&& self.hash_count == other.hash_count
|
||||
&& self.bits == other.bits
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for BloomFilter {}
|
||||
|
||||
impl fmt::Debug for BloomFilter {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("BloomFilter")
|
||||
.field("bits", &self.num_bits)
|
||||
.field("hash_count", &self.hash_count)
|
||||
.field("fill_ratio", &format!("{:.2}%", self.fill_ratio() * 100.0))
|
||||
.field(
|
||||
"est_count",
|
||||
&match self.estimated_count(f64::INFINITY) {
|
||||
Some(n) => format!("{:.0}", n),
|
||||
None => "saturated".to_string(),
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! v1 bloom filter sizing constants (the tunables).
|
||||
|
||||
/// Default filter size in bits (1KB = 8,192 bits).
|
||||
///
|
||||
/// Sized for ~800-1,600 entries. FPR ~0.05% at 400 entries, ~0.9% at 800.
|
||||
/// This is v1 protocol default (size_class=1).
|
||||
pub const DEFAULT_FILTER_SIZE_BITS: usize = 8192;
|
||||
|
||||
/// Default filter size in bytes (1KB).
|
||||
///
|
||||
/// Retained for completeness of the v1 tunable set; no current consumer.
|
||||
#[allow(dead_code)]
|
||||
pub const DEFAULT_FILTER_SIZE_BYTES: usize = DEFAULT_FILTER_SIZE_BITS / 8;
|
||||
|
||||
/// Default number of hash functions.
|
||||
///
|
||||
/// k=5 is optimal at ~1,200 entries and a good compromise for 800-1,600.
|
||||
/// At 400 entries: FPR ~0.05%. At 800 entries: FPR ~0.9%.
|
||||
pub const DEFAULT_HASH_COUNT: u8 = 5;
|
||||
|
||||
/// Size class for v1 protocol (1 KB filters).
|
||||
pub const V1_SIZE_CLASS: u8 = 1;
|
||||
|
||||
/// Filter sizes by size_class: bytes = 512 << size_class
|
||||
///
|
||||
/// Retained for completeness of the v1 tunable set; no current consumer.
|
||||
#[allow(dead_code)]
|
||||
pub const SIZE_CLASS_BYTES: [usize; 4] = [512, 1024, 2048, 4096];
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Sans-IO bloom filter subsystem.
|
||||
//!
|
||||
//! 1KB Bloom filters for reachability in FIPS routing. Each node maintains
|
||||
//! filters that summarize which destinations are reachable through each peer,
|
||||
//! enabling efficient routing decisions without global network knowledge.
|
||||
//!
|
||||
//! ## v1 Parameters
|
||||
//!
|
||||
//! - Size: 1 KB (8,192 bits) - sized for actual ~400-800 entry occupancy
|
||||
//! - Hash functions: k=5 - optimal at ~1,200 entries, good for 800-1,600
|
||||
//! - Bandwidth: 1 KB/announce (75% reduction from original 4KB design)
|
||||
//!
|
||||
//! - `core.rs` — the pure `BloomFilter` data structure.
|
||||
//! - `state.rs` — `BloomState` (per-peer inbound store + outgoing filter
|
||||
//! computation + the send-debounce decision).
|
||||
//! - `limits.rs` — the v1 sizing constants.
|
||||
//! - `wire.rs` — `FilterAnnounce` + `encode`/`decode` (the std-tethered file).
|
||||
//! It imports the shared `ProtocolError` and `LinkMessageType` downward from
|
||||
//! `crate::protocol`.
|
||||
|
||||
mod core;
|
||||
mod limits;
|
||||
mod state;
|
||||
mod wire;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub use core::BloomFilter;
|
||||
pub use limits::{DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT, V1_SIZE_CLASS};
|
||||
pub use state::BloomState;
|
||||
pub use wire::FilterAnnounce;
|
||||
|
||||
/// Errors related to Bloom filter operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BloomError {
|
||||
#[error("invalid filter size: expected {expected} bits, got {got}")]
|
||||
InvalidSize { expected: usize, got: usize },
|
||||
|
||||
#[error("filter size must be a multiple of 8, got {0}")]
|
||||
SizeNotByteAligned(usize),
|
||||
|
||||
#[error("hash count must be positive")]
|
||||
ZeroHashCount,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! FIPS-specific Bloom filter announcement state management.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use alloc::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::{BloomFilter, MAX_SIZE_CLASS, MIN_SIZE_CLASS, V1_SIZE_CLASS, size_class_to_bits};
|
||||
use super::BloomFilter;
|
||||
use crate::NodeAddr;
|
||||
|
||||
/// State for managing Bloom filter announcements.
|
||||
@@ -13,27 +13,19 @@ pub struct BloomState {
|
||||
/// This node's NodeAddr (always included in outgoing filters).
|
||||
own_node_addr: NodeAddr,
|
||||
/// Leaf-only nodes we speak for (included in our filter).
|
||||
leaf_dependents: HashSet<NodeAddr>,
|
||||
leaf_dependents: BTreeSet<NodeAddr>,
|
||||
/// Whether this node operates in leaf-only mode.
|
||||
is_leaf_only: bool,
|
||||
/// This node's filter size class.
|
||||
size_class: u8,
|
||||
/// Rate limiting: minimum interval between outgoing updates (milliseconds).
|
||||
update_debounce_ms: u64,
|
||||
/// Timestamp of last update sent (per peer, in milliseconds).
|
||||
last_update_sent: HashMap<NodeAddr, u64>,
|
||||
last_update_sent: BTreeMap<NodeAddr, u64>,
|
||||
/// Peers that need a filter update.
|
||||
pending_updates: HashSet<NodeAddr>,
|
||||
pending_updates: BTreeSet<NodeAddr>,
|
||||
/// Current sequence number for outgoing filters.
|
||||
sequence: u64,
|
||||
/// Last outgoing filter sent to each peer (for change detection and delta computation).
|
||||
last_sent_filters: HashMap<NodeAddr, BloomFilter>,
|
||||
/// Sequence number of the last filter sent to each peer.
|
||||
last_sent_seq: HashMap<NodeAddr, u64>,
|
||||
/// Fill ratio threshold above which to step up to a larger size class.
|
||||
step_up_threshold: f64,
|
||||
/// Fill ratio threshold below which to step down to a smaller size class.
|
||||
step_down_threshold: f64,
|
||||
/// Last outgoing filter sent to each peer (for change detection).
|
||||
last_sent_filters: BTreeMap<NodeAddr, BloomFilter>,
|
||||
}
|
||||
|
||||
impl BloomState {
|
||||
@@ -41,17 +33,13 @@ impl BloomState {
|
||||
pub fn new(own_node_addr: NodeAddr) -> Self {
|
||||
Self {
|
||||
own_node_addr,
|
||||
leaf_dependents: HashSet::new(),
|
||||
leaf_dependents: BTreeSet::new(),
|
||||
is_leaf_only: false,
|
||||
size_class: V1_SIZE_CLASS,
|
||||
update_debounce_ms: 500,
|
||||
last_update_sent: HashMap::new(),
|
||||
pending_updates: HashSet::new(),
|
||||
last_update_sent: BTreeMap::new(),
|
||||
pending_updates: BTreeSet::new(),
|
||||
sequence: 0,
|
||||
last_sent_filters: HashMap::new(),
|
||||
last_sent_seq: HashMap::new(),
|
||||
step_up_threshold: 0.20,
|
||||
step_down_threshold: 0.05,
|
||||
last_sent_filters: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,19 +60,6 @@ impl BloomState {
|
||||
self.is_leaf_only
|
||||
}
|
||||
|
||||
/// Get the current filter size class.
|
||||
pub fn size_class(&self) -> u8 {
|
||||
self.size_class
|
||||
}
|
||||
|
||||
/// Set the filter size class.
|
||||
///
|
||||
/// This does NOT trigger re-sends; the caller must clear sent filters
|
||||
/// and mark all peers for update.
|
||||
pub fn set_size_class(&mut self, size_class: u8) {
|
||||
self.size_class = size_class;
|
||||
}
|
||||
|
||||
/// Get the current sequence number.
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.sequence
|
||||
@@ -117,7 +92,7 @@ impl BloomState {
|
||||
}
|
||||
|
||||
/// Get the set of leaf dependents.
|
||||
pub fn leaf_dependents(&self) -> &HashSet<NodeAddr> {
|
||||
pub fn leaf_dependents(&self) -> &BTreeSet<NodeAddr> {
|
||||
&self.leaf_dependents
|
||||
}
|
||||
|
||||
@@ -164,43 +139,24 @@ impl BloomState {
|
||||
self.pending_updates.clear();
|
||||
}
|
||||
|
||||
/// Record the outgoing filter and sequence that was sent to a peer.
|
||||
/// Record the outgoing filter that was sent to a peer.
|
||||
pub fn record_sent_filter(&mut self, peer_id: NodeAddr, filter: BloomFilter) {
|
||||
let seq = self.sequence;
|
||||
self.last_sent_filters.insert(peer_id, filter);
|
||||
self.last_sent_seq.insert(peer_id, seq);
|
||||
}
|
||||
|
||||
/// Get the last filter sent to a peer (for delta computation).
|
||||
/// Read back the last outgoing filter actually sent to a peer, if any.
|
||||
///
|
||||
/// Returns the filter recorded by [`record_sent_filter`](Self::record_sent_filter)
|
||||
/// — i.e. what the peer currently holds for us — or `None` when no announce
|
||||
/// has been sent to that peer yet (or the node is root, with no parent to
|
||||
/// send to).
|
||||
pub fn last_sent_filter(&self, peer_id: &NodeAddr) -> Option<&BloomFilter> {
|
||||
self.last_sent_filters.get(peer_id)
|
||||
}
|
||||
|
||||
/// Get the sequence number of the last filter sent to a peer.
|
||||
pub fn last_sent_seq(&self, peer_id: &NodeAddr) -> Option<u64> {
|
||||
self.last_sent_seq.get(peer_id).copied()
|
||||
}
|
||||
|
||||
/// Clear the sent filter for a specific peer (e.g., on NACK).
|
||||
///
|
||||
/// Forces the next send to be a full filter.
|
||||
pub fn clear_sent_filter(&mut self, peer_id: &NodeAddr) {
|
||||
self.last_sent_filters.remove(peer_id);
|
||||
self.last_sent_seq.remove(peer_id);
|
||||
}
|
||||
|
||||
/// Clear all sent filters (e.g., on size class change).
|
||||
///
|
||||
/// Forces full sends to all peers.
|
||||
pub fn clear_all_sent_filters(&mut self) {
|
||||
self.last_sent_filters.clear();
|
||||
self.last_sent_seq.clear();
|
||||
}
|
||||
|
||||
/// Remove stored filter state for a peer that was removed.
|
||||
pub fn remove_peer_state(&mut self, peer_id: &NodeAddr) {
|
||||
self.last_sent_filters.remove(peer_id);
|
||||
self.last_sent_seq.remove(peer_id);
|
||||
self.last_update_sent.remove(peer_id);
|
||||
self.pending_updates.remove(peer_id);
|
||||
}
|
||||
@@ -214,7 +170,7 @@ impl BloomState {
|
||||
&mut self,
|
||||
exclude_from: &NodeAddr,
|
||||
peer_addrs: &[NodeAddr],
|
||||
peer_filters: &HashMap<NodeAddr, BloomFilter>,
|
||||
peer_filters: &BTreeMap<NodeAddr, BloomFilter>,
|
||||
) {
|
||||
for peer_addr in peer_addrs {
|
||||
if peer_addr == exclude_from {
|
||||
@@ -233,24 +189,32 @@ impl BloomState {
|
||||
|
||||
/// Compute the outgoing filter for a specific peer.
|
||||
///
|
||||
/// The filter is created at this node's size class. Peer filters of
|
||||
/// different sizes are automatically converted (folded or duplicated)
|
||||
/// during the merge operation.
|
||||
///
|
||||
/// The filter includes:
|
||||
/// - This node's own ID
|
||||
/// - All leaf dependents
|
||||
/// - Entries from other peers' inbound filters (excluding the destination peer)
|
||||
///
|
||||
/// The `peer_filters` map contains inbound filters from each peer.
|
||||
/// The filter for `exclude_peer` is excluded to prevent routing loops.
|
||||
pub fn compute_outgoing_filter(
|
||||
&self,
|
||||
exclude_peer: &NodeAddr,
|
||||
peer_filters: &HashMap<NodeAddr, BloomFilter>,
|
||||
peer_filters: &BTreeMap<NodeAddr, BloomFilter>,
|
||||
) -> BloomFilter {
|
||||
let mut filter = self.base_filter();
|
||||
let mut filter = BloomFilter::new();
|
||||
|
||||
// Merge filters from other peers (auto-converting sizes)
|
||||
// Always include ourselves
|
||||
filter.insert(&self.own_node_addr);
|
||||
|
||||
// Include leaf dependents
|
||||
for dep in &self.leaf_dependents {
|
||||
filter.insert(dep);
|
||||
}
|
||||
|
||||
// Merge filters from other peers
|
||||
for (peer_id, peer_filter) in peer_filters {
|
||||
if peer_id != exclude_peer {
|
||||
// Ignore merge errors (size mismatches) - just skip that filter
|
||||
let _ = filter.merge(peer_filter);
|
||||
}
|
||||
}
|
||||
@@ -258,27 +222,9 @@ impl BloomState {
|
||||
filter
|
||||
}
|
||||
|
||||
/// Evaluate whether the filter size class should change.
|
||||
///
|
||||
/// Returns `Some(new_class)` if the outgoing fill ratio crosses a
|
||||
/// threshold, `None` if no change is needed.
|
||||
pub fn evaluate_size_change(&self, fill_ratio: f64) -> Option<u8> {
|
||||
if fill_ratio > self.step_up_threshold && self.size_class < MAX_SIZE_CLASS {
|
||||
Some(self.size_class + 1)
|
||||
} else if fill_ratio < self.step_down_threshold && self.size_class > MIN_SIZE_CLASS {
|
||||
Some(self.size_class - 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a base filter containing just this node and its dependents.
|
||||
///
|
||||
/// The filter is created at this node's size class.
|
||||
pub fn base_filter(&self) -> BloomFilter {
|
||||
let num_bits = size_class_to_bits(self.size_class);
|
||||
let mut filter = BloomFilter::with_params(num_bits, super::DEFAULT_HASH_COUNT)
|
||||
.expect("size_class produces valid params");
|
||||
let mut filter = BloomFilter::new();
|
||||
filter.insert(&self.own_node_addr);
|
||||
for dep in &self.leaf_dependents {
|
||||
filter.insert(dep);
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Tests for the `BloomFilter` data structure.
|
||||
|
||||
use crate::proto::bloom::{BloomError, BloomFilter, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
use crate::testutil::make_node_addr;
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_new() {
|
||||
let filter = BloomFilter::new();
|
||||
assert_eq!(filter.num_bits(), DEFAULT_FILTER_SIZE_BITS);
|
||||
assert_eq!(filter.hash_count(), DEFAULT_HASH_COUNT);
|
||||
assert_eq!(filter.count_ones(), 0);
|
||||
assert!(filter.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_insert_contains() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let node1 = make_node_addr(1);
|
||||
let node2 = make_node_addr(2);
|
||||
|
||||
assert!(!filter.contains(&node1));
|
||||
assert!(!filter.contains(&node2));
|
||||
|
||||
filter.insert(&node1);
|
||||
|
||||
assert!(filter.contains(&node1));
|
||||
// node2 might have false positive, but very unlikely with single insert
|
||||
assert!(!filter.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_multiple_inserts() {
|
||||
let mut filter = BloomFilter::new();
|
||||
|
||||
for i in 0..100 {
|
||||
let node = make_node_addr(i);
|
||||
filter.insert(&node);
|
||||
}
|
||||
|
||||
// All inserted items should be found
|
||||
for i in 0..100 {
|
||||
let node = make_node_addr(i);
|
||||
assert!(filter.contains(&node), "Node {} not found", i);
|
||||
}
|
||||
|
||||
// Fill ratio should be reasonable
|
||||
let fill = filter.fill_ratio();
|
||||
assert!(fill > 0.0 && fill < 0.5, "Unexpected fill ratio: {}", fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_merge() {
|
||||
let mut filter1 = BloomFilter::new();
|
||||
let mut filter2 = BloomFilter::new();
|
||||
|
||||
let node1 = make_node_addr(1);
|
||||
let node2 = make_node_addr(2);
|
||||
|
||||
filter1.insert(&node1);
|
||||
filter2.insert(&node2);
|
||||
|
||||
filter1.merge(&filter2).unwrap();
|
||||
|
||||
assert!(filter1.contains(&node1));
|
||||
assert!(filter1.contains(&node2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_union() {
|
||||
let mut filter1 = BloomFilter::new();
|
||||
let mut filter2 = BloomFilter::new();
|
||||
|
||||
let node1 = make_node_addr(1);
|
||||
let node2 = make_node_addr(2);
|
||||
|
||||
filter1.insert(&node1);
|
||||
filter2.insert(&node2);
|
||||
|
||||
let union = filter1.union(&filter2).unwrap();
|
||||
|
||||
assert!(union.contains(&node1));
|
||||
assert!(union.contains(&node2));
|
||||
// Original filters unchanged
|
||||
assert!(!filter1.contains(&node2));
|
||||
assert!(!filter2.contains(&node1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_clear() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let node = make_node_addr(1);
|
||||
|
||||
filter.insert(&node);
|
||||
assert!(!filter.is_empty());
|
||||
|
||||
filter.clear();
|
||||
assert!(filter.is_empty());
|
||||
assert_eq!(filter.count_ones(), 0);
|
||||
assert!(!filter.contains(&node));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_merge_size_mismatch() {
|
||||
let mut filter1 = BloomFilter::with_params(1024, 7).unwrap();
|
||||
let filter2 = BloomFilter::with_params(2048, 7).unwrap();
|
||||
|
||||
let result = filter1.merge(&filter2);
|
||||
assert!(matches!(result, Err(BloomError::InvalidSize { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_custom_params() {
|
||||
let filter = BloomFilter::with_params(1024, 5).unwrap();
|
||||
assert_eq!(filter.num_bits(), 1024);
|
||||
assert_eq!(filter.num_bytes(), 128);
|
||||
assert_eq!(filter.hash_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_invalid_params() {
|
||||
// Not byte-aligned (1001 is not divisible by 8)
|
||||
assert!(matches!(
|
||||
BloomFilter::with_params(1001, 7),
|
||||
Err(BloomError::SizeNotByteAligned(1001))
|
||||
));
|
||||
|
||||
// Zero size
|
||||
assert!(matches!(
|
||||
BloomFilter::with_params(0, 7),
|
||||
Err(BloomError::SizeNotByteAligned(0))
|
||||
));
|
||||
|
||||
// Zero hash count
|
||||
assert!(matches!(
|
||||
BloomFilter::with_params(1024, 0),
|
||||
Err(BloomError::ZeroHashCount)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_from_bytes() {
|
||||
let original = BloomFilter::new();
|
||||
let bytes = original.as_bytes().to_vec();
|
||||
|
||||
let restored = BloomFilter::from_bytes(bytes, original.hash_count()).unwrap();
|
||||
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_estimated_count() {
|
||||
let mut filter = BloomFilter::new();
|
||||
|
||||
// Empty filter
|
||||
assert_eq!(filter.estimated_count(f64::INFINITY), Some(0.0));
|
||||
|
||||
// Insert some items
|
||||
for i in 0..50 {
|
||||
filter.insert(&make_node_addr(i));
|
||||
}
|
||||
|
||||
// Estimate should be reasonably close to 50
|
||||
let estimate = filter.estimated_count(f64::INFINITY).unwrap();
|
||||
assert!(
|
||||
estimate > 30.0 && estimate < 100.0,
|
||||
"Unexpected estimate: {}",
|
||||
estimate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_equality() {
|
||||
let mut filter1 = BloomFilter::new();
|
||||
let mut filter2 = BloomFilter::new();
|
||||
|
||||
assert_eq!(filter1, filter2);
|
||||
|
||||
filter1.insert(&make_node_addr(1));
|
||||
assert_ne!(filter1, filter2);
|
||||
|
||||
filter2.insert(&make_node_addr(1));
|
||||
assert_eq!(filter1, filter2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_from_bytes_empty() {
|
||||
let result = BloomFilter::from_bytes(vec![], 5);
|
||||
assert!(matches!(result, Err(BloomError::SizeNotByteAligned(0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_from_bytes_zero_hash_count() {
|
||||
let result = BloomFilter::from_bytes(vec![0u8; 128], 0);
|
||||
assert!(matches!(result, Err(BloomError::ZeroHashCount)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_from_slice() {
|
||||
let mut original = BloomFilter::new();
|
||||
original.insert(&make_node_addr(42));
|
||||
let bytes = original.as_bytes();
|
||||
|
||||
let restored = BloomFilter::from_slice(bytes, original.hash_count()).unwrap();
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_insert_bytes_contains_bytes() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let data1 = b"hello world";
|
||||
let data2 = b"goodbye";
|
||||
|
||||
assert!(!filter.contains_bytes(data1));
|
||||
|
||||
filter.insert_bytes(data1);
|
||||
assert!(filter.contains_bytes(data1));
|
||||
assert!(!filter.contains_bytes(data2));
|
||||
|
||||
filter.insert_bytes(data2);
|
||||
assert!(filter.contains_bytes(data1));
|
||||
assert!(filter.contains_bytes(data2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_estimated_count_saturated() {
|
||||
// Create a small filter with all bits set
|
||||
let bytes = vec![0xFF; 8]; // all bits set
|
||||
let filter = BloomFilter::from_bytes(bytes, 3).unwrap();
|
||||
|
||||
// Saturated filter returns None regardless of cap (defense in depth).
|
||||
// Previously returned f64::INFINITY.
|
||||
assert_eq!(filter.estimated_count(f64::INFINITY), None);
|
||||
assert_eq!(filter.estimated_count(0.05), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_estimated_count_fpr_cap_boundary() {
|
||||
// Cap boundary: FPR = fill^k = 0.05 at k=5 ⇒ fill ≈ 0.5493
|
||||
// 1KB filter (8192 bits). 560 bytes of 0xFF = 4480 bits set =
|
||||
// fill 0.5469, FPR ≈ 0.04877 — just below cap.
|
||||
// 564 bytes of 0xFF = 4512 bits set = fill 0.5508, FPR ≈ 0.05060 —
|
||||
// just above cap.
|
||||
|
||||
let mut below = vec![0x00u8; 1024];
|
||||
below[..560].fill(0xFF);
|
||||
let below_filter = BloomFilter::from_bytes(below, DEFAULT_HASH_COUNT).unwrap();
|
||||
assert!(
|
||||
below_filter.estimated_count(0.05).is_some(),
|
||||
"fill 0.5469 (FPR ≈ 0.049) must be accepted by cap 0.05"
|
||||
);
|
||||
|
||||
let mut above = vec![0x00u8; 1024];
|
||||
above[..564].fill(0xFF);
|
||||
let above_filter = BloomFilter::from_bytes(above, DEFAULT_HASH_COUNT).unwrap();
|
||||
assert_eq!(
|
||||
above_filter.estimated_count(0.05),
|
||||
None,
|
||||
"fill 0.5508 (FPR ≈ 0.051) must be rejected by cap 0.05"
|
||||
);
|
||||
|
||||
// Same above-cap filter with a looser cap is accepted.
|
||||
assert!(
|
||||
above_filter.estimated_count(0.10).is_some(),
|
||||
"fill 0.5508 (FPR ≈ 0.051) must be accepted by cap 0.10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_default() {
|
||||
let default: BloomFilter = Default::default();
|
||||
let explicit = BloomFilter::new();
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_debug_format() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let debug = format!("{:?}", filter);
|
||||
assert!(debug.contains("BloomFilter"));
|
||||
assert!(debug.contains("8192"));
|
||||
assert!(debug.contains("hash_count"));
|
||||
|
||||
// With some entries
|
||||
for i in 0..10 {
|
||||
filter.insert(&make_node_addr(i));
|
||||
}
|
||||
let debug = format!("{:?}", filter);
|
||||
assert!(debug.contains("fill_ratio"));
|
||||
assert!(debug.contains("est_count"));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Bloom subsystem unit tests, extracted from the co-located `#[cfg(test)]`
|
||||
//! blocks in the sibling source modules.
|
||||
|
||||
mod core;
|
||||
mod state;
|
||||
mod wire;
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Tests for `BloomState` (announcement state management).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::proto::bloom::{BloomFilter, BloomState};
|
||||
use crate::testutil::make_node_addr;
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_new() {
|
||||
let node = make_node_addr(0);
|
||||
let state = BloomState::new(node);
|
||||
|
||||
assert_eq!(state.own_node_addr(), &node);
|
||||
assert!(!state.is_leaf_only());
|
||||
assert_eq!(state.sequence(), 0);
|
||||
assert_eq!(state.leaf_dependent_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_leaf_only() {
|
||||
let node = make_node_addr(0);
|
||||
let state = BloomState::leaf_only(node);
|
||||
|
||||
assert!(state.is_leaf_only());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_leaf_dependents() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let leaf1 = make_node_addr(1);
|
||||
let leaf2 = make_node_addr(2);
|
||||
|
||||
state.add_leaf_dependent(leaf1);
|
||||
state.add_leaf_dependent(leaf2);
|
||||
assert_eq!(state.leaf_dependent_count(), 2);
|
||||
|
||||
assert!(state.remove_leaf_dependent(&leaf1));
|
||||
assert_eq!(state.leaf_dependent_count(), 1);
|
||||
|
||||
assert!(!state.remove_leaf_dependent(&leaf1)); // already removed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_debounce() {
|
||||
let node = make_node_addr(0);
|
||||
let peer = make_node_addr(1);
|
||||
let mut state = BloomState::new(node);
|
||||
state.set_update_debounce_ms(500);
|
||||
|
||||
state.mark_update_needed(peer);
|
||||
|
||||
// Should send initially
|
||||
assert!(state.should_send_update(&peer, 1000));
|
||||
|
||||
// Record send
|
||||
state.record_update_sent(peer, 1000);
|
||||
state.mark_update_needed(peer);
|
||||
|
||||
// Should not send immediately (within debounce)
|
||||
assert!(!state.should_send_update(&peer, 1200));
|
||||
|
||||
// Should send after debounce period
|
||||
assert!(state.should_send_update(&peer, 1600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_sequence() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
assert_eq!(state.sequence(), 0);
|
||||
assert_eq!(state.next_sequence(), 1);
|
||||
assert_eq!(state.next_sequence(), 2);
|
||||
assert_eq!(state.sequence(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_pending_updates() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer2 = make_node_addr(2);
|
||||
|
||||
assert!(!state.needs_update(&peer1));
|
||||
|
||||
state.mark_update_needed(peer1);
|
||||
assert!(state.needs_update(&peer1));
|
||||
assert!(!state.needs_update(&peer2));
|
||||
|
||||
state.mark_all_updates_needed(vec![peer1, peer2]);
|
||||
assert!(state.needs_update(&peer1));
|
||||
assert!(state.needs_update(&peer2));
|
||||
|
||||
state.clear_pending_updates();
|
||||
assert!(!state.needs_update(&peer1));
|
||||
assert!(!state.needs_update(&peer2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_base_filter() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let leaf = make_node_addr(1);
|
||||
state.add_leaf_dependent(leaf);
|
||||
|
||||
let filter = state.base_filter();
|
||||
|
||||
assert!(filter.contains(&node));
|
||||
assert!(filter.contains(&leaf));
|
||||
assert!(!filter.contains(&make_node_addr(99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_compute_outgoing_filter() {
|
||||
let my_node = make_node_addr(0);
|
||||
let mut state = BloomState::new(my_node);
|
||||
|
||||
let leaf = make_node_addr(1);
|
||||
state.add_leaf_dependent(leaf);
|
||||
|
||||
let peer1 = make_node_addr(10);
|
||||
let peer2 = make_node_addr(20);
|
||||
|
||||
// Create peer filters
|
||||
let mut filter1 = BloomFilter::new();
|
||||
filter1.insert(&make_node_addr(100));
|
||||
filter1.insert(&make_node_addr(101));
|
||||
|
||||
let mut filter2 = BloomFilter::new();
|
||||
filter2.insert(&make_node_addr(200));
|
||||
|
||||
let mut peer_filters = BTreeMap::new();
|
||||
peer_filters.insert(peer1, filter1);
|
||||
peer_filters.insert(peer2, filter2);
|
||||
|
||||
// Filter for peer1 should exclude peer1's contributions
|
||||
let outgoing1 = state.compute_outgoing_filter(&peer1, &peer_filters);
|
||||
assert!(outgoing1.contains(&my_node)); // self
|
||||
assert!(outgoing1.contains(&leaf)); // leaf dependent
|
||||
assert!(outgoing1.contains(&make_node_addr(200))); // from peer2
|
||||
// peer1's nodes may or may not be present (depends on split brain)
|
||||
|
||||
// Filter for peer2 should exclude peer2's contributions
|
||||
let outgoing2 = state.compute_outgoing_filter(&peer2, &peer_filters);
|
||||
assert!(outgoing2.contains(&my_node));
|
||||
assert!(outgoing2.contains(&leaf));
|
||||
assert!(outgoing2.contains(&make_node_addr(100))); // from peer1
|
||||
assert!(outgoing2.contains(&make_node_addr(101))); // from peer1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_leaf_dependents_accessor() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let leaf1 = make_node_addr(1);
|
||||
let leaf2 = make_node_addr(2);
|
||||
|
||||
state.add_leaf_dependent(leaf1);
|
||||
state.add_leaf_dependent(leaf2);
|
||||
|
||||
let deps = state.leaf_dependents();
|
||||
assert!(deps.contains(&leaf1));
|
||||
assert!(deps.contains(&leaf2));
|
||||
assert!(!deps.contains(&make_node_addr(99)));
|
||||
assert_eq!(deps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_record_sent_filter() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer = make_node_addr(1);
|
||||
let mut filter = BloomFilter::new();
|
||||
filter.insert(&make_node_addr(42));
|
||||
|
||||
// Record a sent filter, then mark_changed_peers should detect no change
|
||||
// when the outgoing filter matches what was recorded
|
||||
state.record_sent_filter(peer, filter);
|
||||
|
||||
// Compute what would be sent to peer (just our own node, no peer filters)
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_addrs = vec![peer];
|
||||
state.mark_changed_peers(&make_node_addr(99), &peer_addrs, &peer_filters);
|
||||
|
||||
// Outgoing filter (just self) differs from recorded (self + node 42),
|
||||
// so peer should be marked for update
|
||||
assert!(state.needs_update(&peer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_remove_peer_state() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer = make_node_addr(1);
|
||||
|
||||
// Populate all three internal maps for this peer
|
||||
state.mark_update_needed(peer);
|
||||
state.record_update_sent(peer, 1000);
|
||||
state.mark_update_needed(peer); // re-mark after send
|
||||
let filter = BloomFilter::new();
|
||||
state.record_sent_filter(peer, filter);
|
||||
|
||||
assert!(state.needs_update(&peer));
|
||||
|
||||
// Remove all peer state
|
||||
state.remove_peer_state(&peer);
|
||||
|
||||
// Pending updates cleared
|
||||
assert!(!state.needs_update(&peer));
|
||||
|
||||
// Debounce state cleared — should be able to send immediately
|
||||
state.mark_update_needed(peer);
|
||||
assert!(state.should_send_update(&peer, 0));
|
||||
|
||||
// Sent filter cleared — mark_changed_peers should treat as "never sent"
|
||||
state.clear_pending_updates();
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_addrs = vec![peer];
|
||||
state.mark_changed_peers(&make_node_addr(99), &peer_addrs, &peer_filters);
|
||||
assert!(state.needs_update(&peer)); // never sent → must send
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_mark_changed_peers_never_sent() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer2 = make_node_addr(2);
|
||||
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_addrs = vec![peer1, peer2];
|
||||
|
||||
// No filters ever sent — all peers should be marked
|
||||
state.mark_changed_peers(&make_node_addr(99), &peer_addrs, &peer_filters);
|
||||
|
||||
assert!(state.needs_update(&peer1));
|
||||
assert!(state.needs_update(&peer2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_mark_changed_peers_unchanged() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer2 = make_node_addr(2);
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_addrs = vec![peer1, peer2];
|
||||
|
||||
// Compute and record what would be sent to each peer
|
||||
let outgoing1 = state.compute_outgoing_filter(&peer1, &peer_filters);
|
||||
let outgoing2 = state.compute_outgoing_filter(&peer2, &peer_filters);
|
||||
state.record_sent_filter(peer1, outgoing1);
|
||||
state.record_sent_filter(peer2, outgoing2);
|
||||
|
||||
// Nothing changed — no peers should be marked
|
||||
state.mark_changed_peers(&make_node_addr(99), &peer_addrs, &peer_filters);
|
||||
|
||||
assert!(!state.needs_update(&peer1));
|
||||
assert!(!state.needs_update(&peer2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_mark_changed_peers_one_changed() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer2 = make_node_addr(2);
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_addrs = vec![peer1, peer2];
|
||||
|
||||
// Record current outgoing filters for both peers
|
||||
let outgoing1 = state.compute_outgoing_filter(&peer1, &peer_filters);
|
||||
let outgoing2 = state.compute_outgoing_filter(&peer2, &peer_filters);
|
||||
state.record_sent_filter(peer1, outgoing1);
|
||||
state.record_sent_filter(peer2, outgoing2);
|
||||
|
||||
// Now peer1 sends us a filter with new entries
|
||||
let mut inbound_from_peer1 = BloomFilter::new();
|
||||
inbound_from_peer1.insert(&make_node_addr(100));
|
||||
let mut updated_peer_filters = BTreeMap::new();
|
||||
updated_peer_filters.insert(peer1, inbound_from_peer1);
|
||||
|
||||
// mark_changed_peers triggered by receiving from peer1
|
||||
state.mark_changed_peers(&peer1, &peer_addrs, &updated_peer_filters);
|
||||
|
||||
// peer1 is excluded (it's the source), peer2's outgoing changed
|
||||
// (now includes peer1's entries via split-horizon)
|
||||
assert!(!state.needs_update(&peer1));
|
||||
assert!(state.needs_update(&peer2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_mark_changed_peers_excludes_source() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_addrs = vec![peer1];
|
||||
|
||||
// peer1 is both the source and the only peer — should be skipped
|
||||
state.mark_changed_peers(&peer1, &peer_addrs, &peer_filters);
|
||||
|
||||
assert!(!state.needs_update(&peer1));
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Tests for the bloom wire codec (`FilterAnnounce`).
|
||||
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::bloom::FilterAnnounce;
|
||||
use crate::protocol::LinkMessageType;
|
||||
use crate::testutil::make_node_addr;
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_size_class() {
|
||||
let filter = BloomFilter::new();
|
||||
let announce = FilterAnnounce::new(filter.clone(), 100);
|
||||
|
||||
// v1 defaults
|
||||
assert_eq!(announce.size_class, 1);
|
||||
assert_eq!(announce.hash_count, 5);
|
||||
assert!(announce.is_v1_compliant());
|
||||
assert!(announce.is_valid());
|
||||
assert_eq!(announce.filter_size_bytes(), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_with_size_class() {
|
||||
let filter = BloomFilter::with_params(2048 * 8, 7).unwrap();
|
||||
let announce = FilterAnnounce::with_size_class(filter, 100, 2);
|
||||
|
||||
assert_eq!(announce.size_class, 2);
|
||||
assert_eq!(announce.hash_count, 7);
|
||||
assert!(!announce.is_v1_compliant());
|
||||
assert!(announce.is_valid());
|
||||
assert_eq!(announce.filter_size_bytes(), 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_encode_decode_roundtrip() {
|
||||
let mut filter = BloomFilter::new();
|
||||
filter.insert(&make_node_addr(42));
|
||||
filter.insert(&make_node_addr(99));
|
||||
let announce = FilterAnnounce::new(filter, 500);
|
||||
|
||||
let encoded = announce.encode().unwrap();
|
||||
// msg_type(1) + sequence(8) + hash_count(1) + size_class(1) + filter(1024)
|
||||
assert_eq!(encoded.len(), 1035);
|
||||
assert_eq!(encoded[0], LinkMessageType::FilterAnnounce.to_byte());
|
||||
|
||||
// Decode strips msg_type (as dispatcher does)
|
||||
let decoded = FilterAnnounce::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.sequence, 500);
|
||||
assert_eq!(decoded.hash_count, 5);
|
||||
assert_eq!(decoded.size_class, 1);
|
||||
assert!(decoded.is_valid());
|
||||
assert!(decoded.is_v1_compliant());
|
||||
|
||||
// Filter contents preserved
|
||||
assert!(decoded.filter.contains(&make_node_addr(42)));
|
||||
assert!(decoded.filter.contains(&make_node_addr(99)));
|
||||
assert!(!decoded.filter.contains(&make_node_addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_decode_rejects_bad_size_class() {
|
||||
let filter = BloomFilter::new();
|
||||
let announce = FilterAnnounce::new(filter, 100);
|
||||
let mut encoded = announce.encode().unwrap();
|
||||
|
||||
// Corrupt size_class byte (offset: 1 msg_type + 8 seq + 1 hash = 10)
|
||||
encoded[10] = 5; // invalid size_class > MAX_SIZE_CLASS
|
||||
|
||||
let result = FilterAnnounce::decode(&encoded[1..]);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("invalid size_class")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_decode_rejects_non_v1_size_class() {
|
||||
// Build a size_class=0 payload manually (valid range but not v1)
|
||||
let filter = BloomFilter::with_params(512 * 8, 5).unwrap();
|
||||
let announce = FilterAnnounce::with_size_class(filter, 100, 0);
|
||||
let encoded = announce.encode().unwrap();
|
||||
|
||||
let result = FilterAnnounce::decode(&encoded[1..]);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unsupported size_class")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_decode_rejects_truncated() {
|
||||
let result = FilterAnnounce::decode(&[0u8; 5]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! FilterAnnounce message: bloom filter reachability propagation.
|
||||
|
||||
use super::BloomFilter;
|
||||
use crate::protocol::LinkMessageType;
|
||||
use crate::protocol::ProtocolError;
|
||||
|
||||
/// Bloom filter announcement for reachability propagation.
|
||||
///
|
||||
/// Sent to peers to advertise which destinations are reachable.
|
||||
///
|
||||
/// ## Wire Format (v1)
|
||||
///
|
||||
/// | Offset | Field | Size | Notes |
|
||||
/// |--------|-------------|----------|----------------------------------|
|
||||
/// | 0 | msg_type | 1 byte | 0x20 |
|
||||
/// | 1 | sequence | 8 bytes | LE u64 |
|
||||
/// | 9 | hash_count | 1 byte | Number of hash functions |
|
||||
/// | 10 | size_class | 1 byte | Filter size: 512 << size_class |
|
||||
/// | 11 | filter_bits | variable | 512 << size_class bytes |
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FilterAnnounce {
|
||||
/// The bloom filter contents.
|
||||
pub filter: BloomFilter,
|
||||
/// Sequence number for freshness/dedup.
|
||||
pub sequence: u64,
|
||||
/// Number of hash functions used by the filter.
|
||||
pub hash_count: u8,
|
||||
/// Size class: filter size in bytes = 512 << size_class.
|
||||
/// v1 protocol requires size_class=1 (1 KB filters).
|
||||
pub size_class: u8,
|
||||
}
|
||||
|
||||
impl FilterAnnounce {
|
||||
/// Create a new FilterAnnounce message with v1 defaults.
|
||||
pub fn new(filter: BloomFilter, sequence: u64) -> Self {
|
||||
Self {
|
||||
hash_count: filter.hash_count(),
|
||||
size_class: super::V1_SIZE_CLASS,
|
||||
filter,
|
||||
sequence,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with explicit size_class (for testing or future protocol versions).
|
||||
pub fn with_size_class(filter: BloomFilter, sequence: u64, size_class: u8) -> Self {
|
||||
Self {
|
||||
hash_count: filter.hash_count(),
|
||||
size_class,
|
||||
filter,
|
||||
sequence,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the expected filter size in bytes for this size_class.
|
||||
pub fn filter_size_bytes(&self) -> usize {
|
||||
512 << self.size_class
|
||||
}
|
||||
|
||||
/// Validate the filter matches the declared size_class.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.filter.num_bytes() == self.filter_size_bytes()
|
||||
&& self.filter.hash_count() == self.hash_count
|
||||
}
|
||||
|
||||
/// Check if this is a v1-compliant filter (size_class=1).
|
||||
pub fn is_v1_compliant(&self) -> bool {
|
||||
self.size_class == super::V1_SIZE_CLASS
|
||||
}
|
||||
|
||||
/// Minimum payload size after msg_type is stripped:
|
||||
/// sequence(8) + hash_count(1) + size_class(1) = 10
|
||||
const MIN_PAYLOAD_SIZE: usize = 10;
|
||||
|
||||
/// Maximum allowed size_class value.
|
||||
const MAX_SIZE_CLASS: u8 = 3;
|
||||
|
||||
/// Encode as link-layer plaintext (includes msg_type byte).
|
||||
///
|
||||
/// ```text
|
||||
/// [0x20][sequence:8 LE][hash_count:1][size_class:1][filter_bits:variable]
|
||||
/// ```
|
||||
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
if !self.is_valid() {
|
||||
return Err(ProtocolError::Malformed(
|
||||
"filter size does not match size_class".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let filter_bytes = self.filter.as_bytes();
|
||||
let size = 1 + Self::MIN_PAYLOAD_SIZE + filter_bytes.len();
|
||||
let mut buf = Vec::with_capacity(size);
|
||||
|
||||
// msg_type
|
||||
buf.push(LinkMessageType::FilterAnnounce.to_byte());
|
||||
// sequence (8 LE)
|
||||
buf.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
// hash_count
|
||||
buf.push(self.hash_count);
|
||||
// size_class
|
||||
buf.push(self.size_class);
|
||||
// filter_bits
|
||||
buf.extend_from_slice(filter_bytes);
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Decode from link-layer payload (after msg_type byte stripped by dispatcher).
|
||||
///
|
||||
/// The payload starts with the sequence field.
|
||||
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if payload.len() < Self::MIN_PAYLOAD_SIZE {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: Self::MIN_PAYLOAD_SIZE,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
|
||||
// sequence (8 LE)
|
||||
let sequence = u64::from_le_bytes(
|
||||
payload[pos..pos + 8]
|
||||
.try_into()
|
||||
.map_err(|_| ProtocolError::Malformed("bad sequence".into()))?,
|
||||
);
|
||||
pos += 8;
|
||||
|
||||
// hash_count
|
||||
let hash_count = payload[pos];
|
||||
pos += 1;
|
||||
|
||||
// size_class
|
||||
let size_class = payload[pos];
|
||||
pos += 1;
|
||||
|
||||
// Validate size_class range
|
||||
if size_class > Self::MAX_SIZE_CLASS {
|
||||
return Err(ProtocolError::Malformed(format!(
|
||||
"invalid size_class: {size_class} (max {})",
|
||||
Self::MAX_SIZE_CLASS
|
||||
)));
|
||||
}
|
||||
|
||||
// v1 compliance check
|
||||
if size_class != super::V1_SIZE_CLASS {
|
||||
return Err(ProtocolError::Malformed(format!(
|
||||
"unsupported size_class: {size_class} (v1 requires {})",
|
||||
super::V1_SIZE_CLASS
|
||||
)));
|
||||
}
|
||||
|
||||
// Expected filter size from size_class
|
||||
let expected_filter_bytes = 512usize << size_class;
|
||||
let remaining = payload.len() - pos;
|
||||
if remaining != expected_filter_bytes {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: Self::MIN_PAYLOAD_SIZE + expected_filter_bytes,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Construct BloomFilter from bytes
|
||||
let filter = BloomFilter::from_slice(&payload[pos..], hash_count)
|
||||
.map_err(|e| ProtocolError::Malformed(format!("invalid bloom filter: {e}")))?;
|
||||
|
||||
let announce = Self {
|
||||
filter,
|
||||
sequence,
|
||||
hash_count,
|
||||
size_class,
|
||||
};
|
||||
|
||||
Ok(announce)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! A module here has been migrated out of the async node shell; the async
|
||||
//! I/O adapters remain in `node::handlers`.
|
||||
|
||||
pub(crate) mod bloom;
|
||||
pub(crate) mod discovery;
|
||||
pub(crate) mod fmp;
|
||||
pub(crate) mod mmp;
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
//! FilterAnnounce message: bloom filter reachability propagation.
|
||||
//!
|
||||
//! Supports both full sends and delta (XOR diff) updates with RLE compression.
|
||||
|
||||
use super::error::ProtocolError;
|
||||
use super::link::LinkMessageType;
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::bloom::codec::{CompressionStats, rle_decode, rle_encode};
|
||||
|
||||
/// Flag bit: this is a delta (XOR diff) update, not a full filter.
|
||||
const FLAG_DELTA: u8 = 0x01;
|
||||
|
||||
/// FilterAnnounce message for bloom filter reachability propagation.
|
||||
///
|
||||
/// ## Wire Format
|
||||
///
|
||||
/// ```text
|
||||
/// [0x20][flags:1][sequence:8 LE][base_seq:8 LE][size_class:1][compressed_payload]
|
||||
/// ```
|
||||
///
|
||||
/// - `flags` bit 0: is_delta (0 = full filter, 1 = XOR diff)
|
||||
/// - `sequence`: current filter sequence number
|
||||
/// - `base_seq`: for deltas, the sequence this diff is relative to (0 for full)
|
||||
/// - `size_class`: filter size in bytes = 512 << size_class (0-6)
|
||||
/// - `compressed_payload`: RLE-compressed u64 words
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FilterAnnounce {
|
||||
/// The bloom filter contents (full filter or XOR diff).
|
||||
pub filter: BloomFilter,
|
||||
/// Sequence number for this filter update.
|
||||
pub sequence: u64,
|
||||
/// For deltas: the sequence number this diff is relative to.
|
||||
/// For full sends: 0.
|
||||
pub base_seq: u64,
|
||||
/// Size class: filter size in bytes = 512 << size_class.
|
||||
pub size_class: u8,
|
||||
/// Whether this is a delta (XOR diff) update.
|
||||
pub is_delta: bool,
|
||||
}
|
||||
|
||||
impl FilterAnnounce {
|
||||
/// Minimum payload size after msg_type is stripped:
|
||||
/// flags(1) + sequence(8) + base_seq(8) + size_class(1) = 18
|
||||
const MIN_PAYLOAD_SIZE: usize = 18;
|
||||
|
||||
/// Create a full (non-delta) FilterAnnounce.
|
||||
pub fn full(filter: BloomFilter, sequence: u64, size_class: u8) -> Self {
|
||||
Self {
|
||||
filter,
|
||||
sequence,
|
||||
base_seq: 0,
|
||||
size_class,
|
||||
is_delta: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a delta (XOR diff) FilterAnnounce.
|
||||
pub fn delta(diff: BloomFilter, sequence: u64, base_seq: u64, size_class: u8) -> Self {
|
||||
Self {
|
||||
filter: diff,
|
||||
sequence,
|
||||
base_seq,
|
||||
size_class,
|
||||
is_delta: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the expected filter size in bytes for this size_class.
|
||||
pub fn filter_size_bytes(&self) -> usize {
|
||||
512usize << self.size_class
|
||||
}
|
||||
|
||||
/// Validate the filter matches the declared size_class.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.filter.num_bytes() == self.filter_size_bytes()
|
||||
&& (self.size_class as usize) < crate::bloom::SIZE_CLASS_BYTES.len()
|
||||
}
|
||||
|
||||
/// Encode as link-layer plaintext (includes msg_type byte).
|
||||
///
|
||||
/// The filter words are RLE-compressed.
|
||||
pub fn encode(&self) -> Result<(Vec<u8>, CompressionStats), ProtocolError> {
|
||||
if !self.is_valid() {
|
||||
return Err(ProtocolError::Malformed(
|
||||
"filter size does not match size_class".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let (compressed, stats) = rle_encode(self.filter.as_words());
|
||||
let size = 1 + Self::MIN_PAYLOAD_SIZE + compressed.len();
|
||||
let mut buf = Vec::with_capacity(size);
|
||||
|
||||
// msg_type
|
||||
buf.push(LinkMessageType::FilterAnnounce.to_byte());
|
||||
// flags
|
||||
let flags = if self.is_delta { FLAG_DELTA } else { 0 };
|
||||
buf.push(flags);
|
||||
// sequence (8 LE)
|
||||
buf.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
// base_seq (8 LE)
|
||||
buf.extend_from_slice(&self.base_seq.to_le_bytes());
|
||||
// size_class
|
||||
buf.push(self.size_class);
|
||||
// compressed payload
|
||||
buf.extend_from_slice(&compressed);
|
||||
|
||||
Ok((buf, stats))
|
||||
}
|
||||
|
||||
/// Decode from link-layer payload (after msg_type byte stripped by dispatcher).
|
||||
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if payload.len() < Self::MIN_PAYLOAD_SIZE {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: Self::MIN_PAYLOAD_SIZE,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
|
||||
// flags
|
||||
let flags = payload[pos];
|
||||
let is_delta = flags & FLAG_DELTA != 0;
|
||||
pos += 1;
|
||||
|
||||
// sequence (8 LE)
|
||||
let sequence = u64::from_le_bytes(
|
||||
payload[pos..pos + 8]
|
||||
.try_into()
|
||||
.map_err(|_| ProtocolError::Malformed("bad sequence".into()))?,
|
||||
);
|
||||
pos += 8;
|
||||
|
||||
// base_seq (8 LE)
|
||||
let base_seq = u64::from_le_bytes(
|
||||
payload[pos..pos + 8]
|
||||
.try_into()
|
||||
.map_err(|_| ProtocolError::Malformed("bad base_seq".into()))?,
|
||||
);
|
||||
pos += 8;
|
||||
|
||||
// size_class
|
||||
let size_class = payload[pos];
|
||||
pos += 1;
|
||||
|
||||
if (size_class as usize) >= crate::bloom::SIZE_CLASS_BYTES.len() {
|
||||
return Err(ProtocolError::Malformed(format!(
|
||||
"invalid size_class: {size_class} (max {})",
|
||||
crate::bloom::MAX_SIZE_CLASS
|
||||
)));
|
||||
}
|
||||
|
||||
// Decompress RLE payload
|
||||
let expected_bytes = 512usize << size_class;
|
||||
let expected_words = expected_bytes / 8;
|
||||
let compressed_data = &payload[pos..];
|
||||
|
||||
let words = rle_decode(compressed_data, expected_words)
|
||||
.map_err(|e| ProtocolError::Malformed(format!("RLE decode error: {e}")))?;
|
||||
|
||||
// Convert words to bytes for BloomFilter construction
|
||||
let mut bytes = Vec::with_capacity(expected_bytes);
|
||||
for &word in &words {
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
|
||||
let filter = BloomFilter::from_bytes(bytes, crate::bloom::DEFAULT_HASH_COUNT)
|
||||
.map_err(|e| ProtocolError::Malformed(format!("invalid bloom filter: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
filter,
|
||||
sequence,
|
||||
base_seq,
|
||||
size_class,
|
||||
is_delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// FilterNack message: request full filter retransmission.
|
||||
///
|
||||
/// Sent when a node receives an out-of-sequence delta update.
|
||||
///
|
||||
/// ## Wire Format
|
||||
///
|
||||
/// ```text
|
||||
/// [0x21][expected_seq:8 LE]
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FilterNack {
|
||||
/// The sequence number the receiver expected.
|
||||
pub expected_seq: u64,
|
||||
}
|
||||
|
||||
impl FilterNack {
|
||||
/// Encode as link-layer plaintext (includes msg_type byte).
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(9);
|
||||
buf.push(LinkMessageType::FilterNack.to_byte());
|
||||
buf.extend_from_slice(&self.expected_seq.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Decode from link-layer payload (after msg_type byte stripped by dispatcher).
|
||||
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if payload.len() < 8 {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: 8,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
let expected_seq = u64::from_le_bytes(
|
||||
payload[..8]
|
||||
.try_into()
|
||||
.map_err(|_| ProtocolError::Malformed("bad expected_seq".into()))?,
|
||||
);
|
||||
Ok(Self { expected_seq })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::NodeAddr;
|
||||
|
||||
fn make_node_addr(val: u8) -> NodeAddr {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[0] = val;
|
||||
NodeAddr::from_bytes(bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_full_roundtrip() {
|
||||
let mut filter = BloomFilter::new();
|
||||
filter.insert(&make_node_addr(42));
|
||||
filter.insert(&make_node_addr(99));
|
||||
|
||||
let announce = FilterAnnounce::full(filter, 500, 1);
|
||||
assert!(announce.is_valid());
|
||||
assert!(!announce.is_delta);
|
||||
|
||||
let (encoded, stats) = announce.encode().unwrap();
|
||||
assert!(stats.compressed_bytes > 0);
|
||||
assert_eq!(encoded[0], LinkMessageType::FilterAnnounce.to_byte());
|
||||
|
||||
// Decode strips msg_type
|
||||
let decoded = FilterAnnounce::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.sequence, 500);
|
||||
assert_eq!(decoded.base_seq, 0);
|
||||
assert_eq!(decoded.size_class, 1);
|
||||
assert!(!decoded.is_delta);
|
||||
assert!(decoded.filter.contains(&make_node_addr(42)));
|
||||
assert!(decoded.filter.contains(&make_node_addr(99)));
|
||||
assert!(!decoded.filter.contains(&make_node_addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_delta_roundtrip() {
|
||||
let mut old_filter = BloomFilter::new();
|
||||
old_filter.insert(&make_node_addr(1));
|
||||
|
||||
let mut new_filter = BloomFilter::new();
|
||||
new_filter.insert(&make_node_addr(1));
|
||||
new_filter.insert(&make_node_addr(2));
|
||||
|
||||
let diff = old_filter.xor_diff(&new_filter).unwrap();
|
||||
let announce = FilterAnnounce::delta(diff.clone(), 5, 4, 1);
|
||||
assert!(announce.is_delta);
|
||||
|
||||
let (encoded, _) = announce.encode().unwrap();
|
||||
let decoded = FilterAnnounce::decode(&encoded[1..]).unwrap();
|
||||
|
||||
assert_eq!(decoded.sequence, 5);
|
||||
assert_eq!(decoded.base_seq, 4);
|
||||
assert!(decoded.is_delta);
|
||||
assert_eq!(decoded.filter, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_empty_filter_compresses_well() {
|
||||
let filter = BloomFilter::new(); // all zeros
|
||||
let announce = FilterAnnounce::full(filter, 1, 1);
|
||||
let (encoded, stats) = announce.encode().unwrap();
|
||||
|
||||
// 1KB of zeros should compress to ~10 bytes of RLE + 19 bytes header
|
||||
assert!(encoded.len() < 50, "encoded size: {}", encoded.len());
|
||||
assert_eq!(stats.run_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_various_size_classes() {
|
||||
for size_class in 0..=6u8 {
|
||||
let num_bits = crate::bloom::size_class_to_bits(size_class);
|
||||
let filter = BloomFilter::with_params(num_bits, 5).unwrap();
|
||||
let announce = FilterAnnounce::full(filter, 1, size_class);
|
||||
assert!(announce.is_valid());
|
||||
|
||||
let (encoded, _) = announce.encode().unwrap();
|
||||
let decoded = FilterAnnounce::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.size_class, size_class);
|
||||
assert_eq!(decoded.filter.num_bits(), num_bits);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_decode_rejects_bad_size_class() {
|
||||
let filter = BloomFilter::new();
|
||||
let announce = FilterAnnounce::full(filter, 1, 1);
|
||||
let (mut encoded, _) = announce.encode().unwrap();
|
||||
|
||||
// Corrupt size_class byte (offset: 1 msg_type + 1 flags + 8 seq + 8 base_seq = 18)
|
||||
encoded[18] = 7; // invalid
|
||||
|
||||
let result = FilterAnnounce::decode(&encoded[1..]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_announce_decode_rejects_truncated() {
|
||||
let result = FilterAnnounce::decode(&[0u8; 5]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_nack_roundtrip() {
|
||||
let nack = FilterNack { expected_seq: 42 };
|
||||
let encoded = nack.encode();
|
||||
assert_eq!(encoded.len(), 9);
|
||||
assert_eq!(encoded[0], LinkMessageType::FilterNack.to_byte());
|
||||
|
||||
let decoded = FilterNack::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.expected_seq, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_nack_decode_truncated() {
|
||||
let result = FilterNack::decode(&[0u8; 3]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,6 @@ pub enum LinkMessageType {
|
||||
// Bloom filter (0x20-0x2F)
|
||||
/// Bloom filter reachability update (full or delta).
|
||||
FilterAnnounce = 0x20,
|
||||
/// Request full filter retransmission (NACK for out-of-sequence delta).
|
||||
FilterNack = 0x21,
|
||||
|
||||
// Discovery (0x30-0x3F)
|
||||
/// Request to discover a node's coordinates.
|
||||
@@ -60,7 +58,6 @@ impl LinkMessageType {
|
||||
0x02 => Some(LinkMessageType::ReceiverReport),
|
||||
0x10 => Some(LinkMessageType::TreeAnnounce),
|
||||
0x20 => Some(LinkMessageType::FilterAnnounce),
|
||||
0x21 => Some(LinkMessageType::FilterNack),
|
||||
0x30 => Some(LinkMessageType::LookupRequest),
|
||||
0x31 => Some(LinkMessageType::LookupResponse),
|
||||
0x50 => Some(LinkMessageType::Disconnect),
|
||||
@@ -83,7 +80,6 @@ impl fmt::Display for LinkMessageType {
|
||||
LinkMessageType::ReceiverReport => "ReceiverReport",
|
||||
LinkMessageType::TreeAnnounce => "TreeAnnounce",
|
||||
LinkMessageType::FilterAnnounce => "FilterAnnounce",
|
||||
LinkMessageType::FilterNack => "FilterNack",
|
||||
LinkMessageType::LookupRequest => "LookupRequest",
|
||||
LinkMessageType::LookupResponse => "LookupResponse",
|
||||
LinkMessageType::Disconnect => "Disconnect",
|
||||
@@ -262,7 +258,6 @@ mod tests {
|
||||
let types = [
|
||||
LinkMessageType::TreeAnnounce,
|
||||
LinkMessageType::FilterAnnounce,
|
||||
LinkMessageType::FilterNack,
|
||||
LinkMessageType::LookupRequest,
|
||||
LinkMessageType::LookupResponse,
|
||||
LinkMessageType::SessionDatagram,
|
||||
|
||||
@@ -21,13 +21,11 @@
|
||||
//! layer, encrypted end-to-end independently of per-hop link encryption.
|
||||
|
||||
mod error;
|
||||
mod filter;
|
||||
mod link;
|
||||
pub(crate) mod session;
|
||||
|
||||
// Re-export all public types at protocol:: level
|
||||
pub use error::ProtocolError;
|
||||
pub use filter::{FilterAnnounce, FilterNack};
|
||||
pub use link::{
|
||||
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user