Files
fips/src/node/tests/bloom_poison.rs
T
Johnathan Corgan 08b8b3908e node: extract immutable state into a shared context and atomic metric registry
Store node counters in an atomic metric registry read through &self, and
introduce a shared NodeContext bundle holding the effectively-immutable
fields (config, identity, startup epoch, capability limits). Source the
immutable config and identity reads across the receive hot path, the
handshake/session/mmp/encrypted state machines, and the discovery, tree,
bloom, retry, and lifecycle modules through the context accessors rather
than direct field reads. The Node fields and the context are rebuilt in
lockstep at every mutation site.
2026-06-02 13:05:03 +00:00

170 lines
5.9 KiB
Rust

//! Direct tests for the M1 antipoison FPR cap in handle_filter_announce.
//!
//! These tests construct a minimal Node with a single synthetic peer,
//! then call handle_filter_announce directly with crafted FilterAnnounce
//! payloads. Focused on the ingress check semantics; broader
//! filter-exchange behavior is covered by the multi-node tests in
//! bloom.rs.
use super::*;
use crate::bloom::{BloomFilter, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
use crate::peer::ActivePeer;
use crate::protocol::FilterAnnounce;
/// Inject a synthetic active peer into the node with a known NodeAddr.
/// Returns the peer's NodeAddr.
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_payload(announce: &FilterAnnounce) -> Vec<u8> {
let mut full = announce.encode().unwrap();
full.remove(0); // strip msg_type byte
full
}
#[tokio::test]
async fn test_m1_rejects_all_ones_filter_announce() {
let mut node = make_node();
let peer_addr = inject_peer(&mut node);
// Craft an all-ones FilterAnnounce (the observed-in-the-wild attack).
let all_ones = BloomFilter::from_bytes(
vec![0xFFu8; DEFAULT_FILTER_SIZE_BITS / 8],
DEFAULT_HASH_COUNT,
)
.unwrap();
let announce = FilterAnnounce::new(all_ones, 1);
let payload = encode_payload(&announce);
let before_fill_exceeded = node.metrics().bloom.fill_exceeded.get();
let before_accepted = node.metrics().bloom.accepted.get();
node.handle_filter_announce(&peer_addr, &payload).await;
// The rejection path bumps the counter once, through the typed
// record_reject dispatch. The legacy direct bump has been removed,
// so the rejection-path event yields a +1 delta.
assert_eq!(
node.metrics().bloom.fill_exceeded.get(),
before_fill_exceeded + 1,
"fill_exceeded counter must increment on all-ones rejection"
);
assert_eq!(
node.metrics().bloom.accepted.get(),
before_accepted,
"accepted counter must NOT increment on rejection"
);
// Peer state unchanged: no filter stored, sequence not advanced.
let peer = node.get_peer(&peer_addr).expect("peer still present");
assert!(
peer.inbound_filter().is_none(),
"peer must NOT have a stored filter after rejection"
);
assert_eq!(
peer.filter_sequence(),
0,
"peer filter_sequence must NOT advance on rejection"
);
}
#[tokio::test]
async fn test_m1_accepts_sub_cap_filter() {
let mut node = make_node();
let peer_addr = inject_peer(&mut node);
// A legitimate filter with 50 entries — fill ~0.03, FPR ~2e-8,
// far below the 0.05 cap. Represents normal mesh traffic.
let mut filter = BloomFilter::new();
for i in 0..50u8 {
let mut bytes = [0u8; 16];
bytes[0] = i;
filter.insert(&NodeAddr::from_bytes(bytes));
}
let announce = FilterAnnounce::new(filter, 1);
let payload = encode_payload(&announce);
let before_fill_exceeded = node.metrics().bloom.fill_exceeded.get();
let before_accepted = node.metrics().bloom.accepted.get();
node.handle_filter_announce(&peer_addr, &payload).await;
assert_eq!(
node.metrics().bloom.fill_exceeded.get(),
before_fill_exceeded,
"fill_exceeded must NOT increment on legitimate sub-cap filter"
);
assert_eq!(
node.metrics().bloom.accepted.get(),
before_accepted + 1,
"accepted must increment on legitimate filter"
);
// Peer state updated: filter stored, sequence advanced.
let peer = node.get_peer(&peer_addr).expect("peer still present");
assert!(
peer.inbound_filter().is_some(),
"peer must have a stored filter after acceptance"
);
assert_eq!(
peer.filter_sequence(),
1,
"peer filter_sequence must advance to announce's sequence"
);
}
#[tokio::test]
async fn test_m1_sequence_not_advanced_allows_recovery() {
// Confirms the "keep prior filter, don't advance seq" rejection
// semantics: a compliant announce after a rejected one still
// succeeds at seq=1, because the rejected announce (also seq=1)
// did not advance the peer's recorded sequence.
let mut node = make_node();
let peer_addr = inject_peer(&mut node);
// First announce: all-ones, rejected.
let bad = BloomFilter::from_bytes(
vec![0xFFu8; DEFAULT_FILTER_SIZE_BITS / 8],
DEFAULT_HASH_COUNT,
)
.unwrap();
let bad_announce = FilterAnnounce::new(bad, 1);
node.handle_filter_announce(&peer_addr, &encode_payload(&bad_announce))
.await;
assert_eq!(
node.get_peer(&peer_addr).unwrap().filter_sequence(),
0,
"rejected announce must not advance sequence"
);
// Second announce: legitimate, seq=1 (would be stale if rejection
// had advanced the recorded sequence). Must be accepted.
let mut good = BloomFilter::new();
for i in 0..10u8 {
let mut bytes = [0u8; 16];
bytes[0] = i;
good.insert(&NodeAddr::from_bytes(bytes));
}
let good_announce = FilterAnnounce::new(good, 1);
node.handle_filter_announce(&peer_addr, &encode_payload(&good_announce))
.await;
let peer = node.get_peer(&peer_addr).unwrap();
assert!(
peer.inbound_filter().is_some(),
"compliant announce at same seq must be accepted after rejection"
);
assert_eq!(peer.filter_sequence(), 1);
// The typed record_reject dispatch increments the counter once; the
// legacy direct bump has been removed.
assert_eq!(node.metrics().bloom.fill_exceeded.get(), 1);
assert_eq!(node.metrics().bloom.accepted.get(), 1);
}