proto/bloom: relocate bloom filter into sans-IO layout

Migrate the v1 bloom filter subsystem into src/proto/bloom/, matching the
discovery/routing/fmp/mmp/stp reference layout and completing the proto/
relocation series for the data/wire subsystems.

- wire.rs: the FilterAnnounce (0x20) codec, moved from protocol/filter.rs
- core.rs: the pure BloomFilter algorithm (hash/insert/contains/merge/
  as_bytes/from_bytes/estimated_count), moved from bloom/filter.rs
- state.rs: BloomState (per-peer inbound store, compute_outgoing_filter,
  the injected-clock send debounce), moved from bloom/state.rs
- limits.rs: the v1 sizing constants
- mod.rs: module wiring + the BloomError enum
- tests/: the unit suite split by target (core/state/wire), no inline tests

no_std+alloc hygiene: core::fmt over std::fmt, the tracing dependency
dropped from the pure filter, and std collections replaced with
BTreeMap/BTreeSet (NodeAddr: Ord) for deterministic iteration. The pure
filter combination stays a BloomState method; the two irreducible shell
gathers (peer_inbound_filters, build_filter_announce) remain in the async
shell. Wire bytes and observable behavior are unchanged; full local CI
green (36/36) including the bloom-storm chaos gate.
This commit is contained in:
Johnathan Corgan
2026-07-07 23:10:12 +00:00
parent a67801099d
commit 4ed674ea8b
20 changed files with 525 additions and 515 deletions
-60
View File
@@ -1,60 +0,0 @@
//! Bloom Filter Implementation
//!
//! 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)
//!
//! These parameters are right-sized for typical network occupancy of
//! ~250-800 entries per node.
mod filter;
mod state;
use thiserror::Error;
pub use filter::BloomFilter;
pub use state::BloomState;
/// 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).
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
pub const SIZE_CLASS_BYTES: [usize; 4] = [512, 1024, 2048, 4096];
/// 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,
}
#[cfg(test)]
mod tests;
+7 -5
View File
@@ -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};
+5 -5
View File
@@ -4,12 +4,12 @@
//! including debounced propagation to peers.
use crate::NodeAddr;
use crate::bloom::BloomFilter;
use crate::protocol::FilterAnnounce;
use crate::proto::bloom::BloomFilter;
use crate::proto::bloom::FilterAnnounce;
use super::reject::BloomReject;
use super::{Node, NodeError};
use std::collections::HashMap;
use std::collections::BTreeMap;
use tracing::{debug, warn};
impl Node {
@@ -17,8 +17,8 @@ impl Node {
///
/// Returns a map of (peer_node_addr -> filter) for peers that
/// have sent us a FilterAnnounce.
pub(super) fn peer_inbound_filters(&self) -> HashMap<NodeAddr, BloomFilter> {
let mut filters = HashMap::new();
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)
&& let Some(filter) = peer.inbound_filter()
+1 -1
View File
@@ -40,10 +40,10 @@ use self::wire::{
ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, 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::mmp::Mmp;
+3 -3
View File
@@ -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();
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -1038,8 +1038,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;
+1 -1
View File
@@ -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
View File
@@ -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::mmp::MmpPeerState;
use crate::proto::stp::{ParentDeclaration, TreeCoordinate};
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
@@ -1,8 +1,6 @@
//! Generic Bloom filter data structure.
use std::fmt;
use tracing::trace;
use core::fmt;
use super::{BloomError, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
use crate::NodeAddr;
@@ -164,7 +162,6 @@ impl BloomFilter {
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;
}
+28
View File
@@ -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];
+47
View File
@@ -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,
}
+12 -12
View File
@@ -1,6 +1,6 @@
//! FIPS-specific Bloom filter announcement state management.
use std::collections::{HashMap, HashSet};
use alloc::collections::{BTreeMap, BTreeSet};
use super::BloomFilter;
use crate::NodeAddr;
@@ -13,19 +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,
/// 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).
last_sent_filters: HashMap<NodeAddr, BloomFilter>,
last_sent_filters: BTreeMap<NodeAddr, BloomFilter>,
}
impl BloomState {
@@ -33,13 +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,
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_filters: BTreeMap::new(),
}
}
@@ -92,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
}
@@ -170,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 {
@@ -199,7 +199,7 @@ impl BloomState {
pub fn compute_outgoing_filter(
&self,
exclude_peer: &NodeAddr,
peer_filters: &HashMap<NodeAddr, BloomFilter>,
peer_filters: &BTreeMap<NodeAddr, BloomFilter>,
) -> BloomFilter {
let mut filter = BloomFilter::new();
+290
View File
@@ -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"));
}
+6
View File
@@ -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;
@@ -1,302 +1,9 @@
use super::*;
use crate::NodeAddr;
use std::collections::HashMap;
//! Tests for `BloomState` (announcement state management).
fn make_node_addr(val: u8) -> NodeAddr {
let mut bytes = [0u8; 16];
bytes[0] = val;
NodeAddr::from_bytes(bytes)
}
use std::collections::BTreeMap;
// ===== BloomFilter Tests =====
#[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"));
}
// ===== BloomState Tests =====
use crate::proto::bloom::{BloomFilter, BloomState};
use crate::testutil::make_node_addr;
#[test]
fn test_bloom_state_new() {
@@ -426,7 +133,7 @@ fn test_bloom_state_compute_outgoing_filter() {
let mut filter2 = BloomFilter::new();
filter2.insert(&make_node_addr(200));
let mut peer_filters = HashMap::new();
let mut peer_filters = BTreeMap::new();
peer_filters.insert(peer1, filter1);
peer_filters.insert(peer2, filter2);
@@ -477,7 +184,7 @@ fn test_bloom_state_record_sent_filter() {
state.record_sent_filter(peer, filter);
// Compute what would be sent to peer (just our own node, no peer filters)
let peer_filters = HashMap::new();
let peer_filters = BTreeMap::new();
let peer_addrs = vec![peer];
state.mark_changed_peers(&make_node_addr(99), &peer_addrs, &peer_filters);
@@ -514,7 +221,7 @@ fn test_bloom_state_remove_peer_state() {
// Sent filter cleared — mark_changed_peers should treat as "never sent"
state.clear_pending_updates();
let peer_filters = HashMap::new();
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
@@ -528,7 +235,7 @@ fn test_bloom_state_mark_changed_peers_never_sent() {
let peer1 = make_node_addr(1);
let peer2 = make_node_addr(2);
let peer_filters = HashMap::new();
let peer_filters = BTreeMap::new();
let peer_addrs = vec![peer1, peer2];
// No filters ever sent — all peers should be marked
@@ -545,7 +252,7 @@ fn test_bloom_state_mark_changed_peers_unchanged() {
let peer1 = make_node_addr(1);
let peer2 = make_node_addr(2);
let peer_filters = HashMap::new();
let peer_filters = BTreeMap::new();
let peer_addrs = vec![peer1, peer2];
// Compute and record what would be sent to each peer
@@ -568,7 +275,7 @@ fn test_bloom_state_mark_changed_peers_one_changed() {
let peer1 = make_node_addr(1);
let peer2 = make_node_addr(2);
let peer_filters = HashMap::new();
let peer_filters = BTreeMap::new();
let peer_addrs = vec![peer1, peer2];
// Record current outgoing filters for both peers
@@ -580,7 +287,7 @@ fn test_bloom_state_mark_changed_peers_one_changed() {
// 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 = HashMap::new();
let mut updated_peer_filters = BTreeMap::new();
updated_peer_filters.insert(peer1, inbound_from_peer1);
// mark_changed_peers triggered by receiving from peer1
@@ -598,7 +305,7 @@ fn test_bloom_state_mark_changed_peers_excludes_source() {
let mut state = BloomState::new(node);
let peer1 = make_node_addr(1);
let peer_filters = HashMap::new();
let peer_filters = BTreeMap::new();
let peer_addrs = vec![peer1];
// peer1 is both the source and the only peer — should be skipped
+99
View File
@@ -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());
}
@@ -1,8 +1,8 @@
//! FilterAnnounce message: bloom filter reachability propagation.
use super::error::ProtocolError;
use super::link::LinkMessageType;
use crate::bloom::BloomFilter;
use super::BloomFilter;
use crate::protocol::LinkMessageType;
use crate::protocol::ProtocolError;
/// Bloom filter announcement for reachability propagation.
///
@@ -35,7 +35,7 @@ impl FilterAnnounce {
pub fn new(filter: BloomFilter, sequence: u64) -> Self {
Self {
hash_count: filter.hash_count(),
size_class: crate::bloom::V1_SIZE_CLASS,
size_class: super::V1_SIZE_CLASS,
filter,
sequence,
}
@@ -64,7 +64,7 @@ impl FilterAnnounce {
/// Check if this is a v1-compliant filter (size_class=1).
pub fn is_v1_compliant(&self) -> bool {
self.size_class == crate::bloom::V1_SIZE_CLASS
self.size_class == super::V1_SIZE_CLASS
}
/// Minimum payload size after msg_type is stripped:
@@ -142,10 +142,10 @@ impl FilterAnnounce {
}
// v1 compliance check
if size_class != crate::bloom::V1_SIZE_CLASS {
if size_class != super::V1_SIZE_CLASS {
return Err(ProtocolError::Malformed(format!(
"unsupported size_class: {size_class} (v1 requires {})",
crate::bloom::V1_SIZE_CLASS
super::V1_SIZE_CLASS
)));
}
@@ -160,7 +160,7 @@ impl FilterAnnounce {
}
// Construct BloomFilter from bytes
let filter = crate::bloom::BloomFilter::from_slice(&payload[pos..], hash_count)
let filter = BloomFilter::from_slice(&payload[pos..], hash_count)
.map_err(|e| ProtocolError::Malformed(format!("invalid bloom filter: {e}")))?;
let announce = Self {
@@ -173,108 +173,3 @@ impl FilterAnnounce {
Ok(announce)
}
}
#[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_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());
}
}
+1
View File
@@ -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;
-2
View File
@@ -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;
pub use link::{
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
};