From 4ed674ea8bad5546595b87e8b11eaa2f71bcda3f Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 7 Jul 2026 23:10:12 +0000 Subject: [PATCH] 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. --- src/bloom/mod.rs | 60 ---- src/lib.rs | 12 +- src/node/bloom.rs | 10 +- src/node/mod.rs | 2 +- src/node/tests/bloom.rs | 6 +- src/node/tests/bloom_poison.rs | 4 +- src/node/tests/discovery.rs | 2 +- src/node/tests/routing.rs | 2 +- src/peer/active.rs | 2 +- src/{bloom/filter.rs => proto/bloom/core.rs} | 5 +- src/proto/bloom/limits.rs | 28 ++ src/proto/bloom/mod.rs | 47 +++ src/{ => proto}/bloom/state.rs | 24 +- src/proto/bloom/tests/core.rs | 290 ++++++++++++++++ src/proto/bloom/tests/mod.rs | 6 + .../tests.rs => proto/bloom/tests/state.rs} | 317 +----------------- src/proto/bloom/tests/wire.rs | 99 ++++++ .../filter.rs => proto/bloom/wire.rs} | 121 +------ src/proto/mod.rs | 1 + src/protocol/mod.rs | 2 - 20 files changed, 525 insertions(+), 515 deletions(-) delete mode 100644 src/bloom/mod.rs rename src/{bloom/filter.rs => proto/bloom/core.rs} (98%) create mode 100644 src/proto/bloom/limits.rs create mode 100644 src/proto/bloom/mod.rs rename src/{ => proto}/bloom/state.rs (92%) create mode 100644 src/proto/bloom/tests/core.rs create mode 100644 src/proto/bloom/tests/mod.rs rename src/{bloom/tests.rs => proto/bloom/tests/state.rs} (52%) create mode 100644 src/proto/bloom/tests/wire.rs rename src/{protocol/filter.rs => proto/bloom/wire.rs} (57%) diff --git a/src/bloom/mod.rs b/src/bloom/mod.rs deleted file mode 100644 index 663bd99..0000000 --- a/src/bloom/mod.rs +++ /dev/null @@ -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; diff --git a/src/lib.rs b/src/lib.rs index 96c5c1f..61efc19 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/src/node/bloom.rs b/src/node/bloom.rs index f16c243..17e67de 100644 --- a/src/node/bloom.rs +++ b/src/node/bloom.rs @@ -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 { - let mut filters = HashMap::new(); + pub(super) fn peer_inbound_filters(&self) -> BTreeMap { + let mut filters = BTreeMap::new(); for (addr, peer) in &self.peers { if self.is_tree_peer(addr) && let Some(filter) = peer.inbound_filter() diff --git a/src/node/mod.rs b/src/node/mod.rs index f40190b..b786ae3 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -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; diff --git a/src/node/tests/bloom.rs b/src/node/tests/bloom.rs index 48d090a..268fc23 100644 --- a/src/node/tests/bloom.rs +++ b/src/node/tests/bloom.rs @@ -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(); diff --git a/src/node/tests/bloom_poison.rs b/src/node/tests/bloom_poison.rs index e09437b..d29cf67 100644 --- a/src/node/tests/bloom_poison.rs +++ b/src/node/tests/bloom_poison.rs @@ -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. diff --git a/src/node/tests/discovery.rs b/src/node/tests/discovery.rs index 4be39ec..7d823c8 100644 --- a/src/node/tests/discovery.rs +++ b/src/node/tests/discovery.rs @@ -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; diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs index 48fa688..5123581 100644 --- a/src/node/tests/routing.rs +++ b/src/node/tests/routing.rs @@ -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, diff --git a/src/peer/active.rs b/src/peer/active.rs index 1b474ab..9c9aea4 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -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}; diff --git a/src/bloom/filter.rs b/src/proto/bloom/core.rs similarity index 98% rename from src/bloom/filter.rs rename to src/proto/bloom/core.rs index 3566862..2876faf 100644 --- a/src/bloom/filter.rs +++ b/src/proto/bloom/core.rs @@ -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; } diff --git a/src/proto/bloom/limits.rs b/src/proto/bloom/limits.rs new file mode 100644 index 0000000..bef6c27 --- /dev/null +++ b/src/proto/bloom/limits.rs @@ -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]; diff --git a/src/proto/bloom/mod.rs b/src/proto/bloom/mod.rs new file mode 100644 index 0000000..0a00c9f --- /dev/null +++ b/src/proto/bloom/mod.rs @@ -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, +} diff --git a/src/bloom/state.rs b/src/proto/bloom/state.rs similarity index 92% rename from src/bloom/state.rs rename to src/proto/bloom/state.rs index 5a0f5c8..abbd451 100644 --- a/src/bloom/state.rs +++ b/src/proto/bloom/state.rs @@ -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, + leaf_dependents: BTreeSet, /// 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, + last_update_sent: BTreeMap, /// Peers that need a filter update. - pending_updates: HashSet, + pending_updates: BTreeSet, /// Current sequence number for outgoing filters. sequence: u64, /// Last outgoing filter sent to each peer (for change detection). - last_sent_filters: HashMap, + last_sent_filters: BTreeMap, } 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 { + pub fn leaf_dependents(&self) -> &BTreeSet { &self.leaf_dependents } @@ -170,7 +170,7 @@ impl BloomState { &mut self, exclude_from: &NodeAddr, peer_addrs: &[NodeAddr], - peer_filters: &HashMap, + peer_filters: &BTreeMap, ) { 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, + peer_filters: &BTreeMap, ) -> BloomFilter { let mut filter = BloomFilter::new(); diff --git a/src/proto/bloom/tests/core.rs b/src/proto/bloom/tests/core.rs new file mode 100644 index 0000000..b081427 --- /dev/null +++ b/src/proto/bloom/tests/core.rs @@ -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")); +} diff --git a/src/proto/bloom/tests/mod.rs b/src/proto/bloom/tests/mod.rs new file mode 100644 index 0000000..35c691d --- /dev/null +++ b/src/proto/bloom/tests/mod.rs @@ -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; diff --git a/src/bloom/tests.rs b/src/proto/bloom/tests/state.rs similarity index 52% rename from src/bloom/tests.rs rename to src/proto/bloom/tests/state.rs index df23c8e..6c74746 100644 --- a/src/bloom/tests.rs +++ b/src/proto/bloom/tests/state.rs @@ -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 diff --git a/src/proto/bloom/tests/wire.rs b/src/proto/bloom/tests/wire.rs new file mode 100644 index 0000000..f6a5e58 --- /dev/null +++ b/src/proto/bloom/tests/wire.rs @@ -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()); +} diff --git a/src/protocol/filter.rs b/src/proto/bloom/wire.rs similarity index 57% rename from src/protocol/filter.rs rename to src/proto/bloom/wire.rs index 601b9a9..5946a4c 100644 --- a/src/protocol/filter.rs +++ b/src/proto/bloom/wire.rs @@ -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()); - } -} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 1afa3c1..44c404b 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -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; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index a1771a4..0998454 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -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, };