use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use dashmap::DashMap;
use freenet_stdlib::prelude::ContractInstanceId;
use tokio::time::Instant;
use crate::util::time_source::TimeSource;
pub(crate) const MAX_BANNED_CONTRACTS: usize = 8_192;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum BanReason {
AutoMad,
#[allow(dead_code)]
Operator,
}
#[derive(Clone, Copy, Debug)]
struct BanEntry {
expires_at: Instant,
reason: BanReason,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum BanOutcome {
Banned,
Updated,
CapacityExceeded,
}
pub(crate) struct ContractBanList {
entries: DashMap<ContractInstanceId, BanEntry>,
size: AtomicUsize,
max_banned: usize,
capacity_rejected_total: AtomicU64,
time_source: Arc<dyn TimeSource + Send + Sync>,
}
impl ContractBanList {
pub fn new(time_source: Arc<dyn TimeSource + Send + Sync>) -> Self {
Self::with_max(time_source, MAX_BANNED_CONTRACTS)
}
pub fn with_max(time_source: Arc<dyn TimeSource + Send + Sync>, max_banned: usize) -> Self {
Self {
entries: DashMap::new(),
size: AtomicUsize::new(0),
max_banned,
capacity_rejected_total: AtomicU64::new(0),
time_source,
}
}
pub fn is_banned(&self, contract: &ContractInstanceId) -> bool {
match self.entries.get(contract) {
None => false,
Some(entry) => {
let now = self.time_source.now();
now < entry.expires_at
}
}
}
pub fn ban(
&self,
contract: ContractInstanceId,
expires_at: Instant,
reason: BanReason,
) -> BanOutcome {
use dashmap::mapref::entry::Entry;
loop {
match self.entries.entry(contract) {
Entry::Occupied(mut e) => {
let cur = e.get_mut();
if expires_at > cur.expires_at {
cur.expires_at = expires_at;
}
if matches!(reason, BanReason::AutoMad) {
cur.reason = BanReason::AutoMad;
}
return BanOutcome::Updated;
}
Entry::Vacant(e) => {
let prev = self.size.fetch_add(1, Ordering::Relaxed);
if prev < self.max_banned {
e.insert(BanEntry { expires_at, reason });
return BanOutcome::Banned;
}
self.size.fetch_sub(1, Ordering::Relaxed);
drop(e);
if matches!(reason, BanReason::AutoMad) && self.evict_one_operator() {
continue;
}
self.capacity_rejected_total.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
%contract,
?reason,
max_banned = self.max_banned,
"contract ban rejected: ban list at capacity"
);
return BanOutcome::CapacityExceeded;
}
}
}
}
fn evict_one_operator(&self) -> bool {
let mut evicted = false;
self.entries.retain(|_, entry| {
if !evicted && matches!(entry.reason, BanReason::Operator) {
evicted = true;
false } else {
true
}
});
if evicted {
self.size.fetch_sub(1, Ordering::Relaxed);
}
evicted
}
pub fn unban(&self, contract: &ContractInstanceId) {
if self.entries.remove(contract).is_some() {
self.size.fetch_sub(1, Ordering::Relaxed);
}
}
pub fn cleanup(&self) {
let now = self.time_source.now();
let mut removed = 0usize;
self.entries.retain(|_, entry| {
let keep = entry.expires_at > now;
if !keep {
removed += 1;
}
keep
});
if removed > 0 {
self.size.fetch_sub(removed, Ordering::Relaxed);
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn len(&self) -> usize {
self.entries.len()
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn capacity_rejected_total(&self) -> u64 {
self.capacity_rejected_total.load(Ordering::Relaxed)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn snapshot(&self) -> Vec<BanListEntry> {
let now = self.time_source.now();
self.entries
.iter()
.filter(|e| now < e.value().expires_at)
.map(|e| BanListEntry {
contract: *e.key(),
reason: e.value().reason,
remaining: e.value().expires_at.saturating_duration_since(now),
})
.collect()
}
}
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, Copy, Debug)]
pub(crate) struct BanListEntry {
pub contract: ContractInstanceId,
pub reason: BanReason,
pub remaining: std::time::Duration,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::governance::{GovernanceState, ReaperDecision, TransitionReason};
use crate::ring::Ring;
use crate::util::time_source::SharedMockTimeSource;
use std::time::Duration;
fn mk_contract(byte: u8) -> ContractInstanceId {
ContractInstanceId::new([byte; 32])
}
fn mk_decision(
contract: ContractInstanceId,
reason: TransitionReason,
at: Instant,
) -> ReaperDecision {
#[allow(clippy::wildcard_enum_match_arm)]
let (from, to) = match reason {
TransitionReason::BanTriggered => (GovernanceState::Evicted, GovernanceState::Banned),
TransitionReason::BanLifted => (GovernanceState::Banned, GovernanceState::Normal),
_ => (GovernanceState::Normal, GovernanceState::Normal),
};
ReaperDecision {
key: contract,
from,
to,
reason,
at,
actionable: true,
}
}
fn mk_ban_list() -> (ContractBanList, SharedMockTimeSource) {
let ts = SharedMockTimeSource::new();
let bl = ContractBanList::new(Arc::new(ts.clone()));
(bl, ts)
}
#[test]
fn unbanned_contract_returns_false() {
let (bl, _ts) = mk_ban_list();
assert!(!bl.is_banned(&mk_contract(1)));
}
#[test]
fn banned_contract_returns_true_until_expiry() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
let now = ts.now();
bl.ban(contract, now + Duration::from_secs(60), BanReason::AutoMad);
assert!(bl.is_banned(&contract));
ts.advance_time(Duration::from_secs(59));
assert!(bl.is_banned(&contract));
ts.advance_time(Duration::from_secs(1));
assert!(!bl.is_banned(&contract));
}
#[test]
fn unban_removes_entry_immediately() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
bl.ban(
contract,
ts.now() + Duration::from_secs(60),
BanReason::AutoMad,
);
assert!(bl.is_banned(&contract));
bl.unban(&contract);
assert!(!bl.is_banned(&contract));
assert_eq!(bl.len(), 0);
}
#[test]
fn rebanning_extends_window_not_shortens() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
let start = ts.now();
bl.ban(
contract,
start + Duration::from_secs(120),
BanReason::AutoMad,
);
bl.ban(
contract,
start + Duration::from_secs(30),
BanReason::AutoMad,
);
ts.advance_time(Duration::from_secs(60));
assert!(
bl.is_banned(&contract),
"re-banning with earlier expiry must not shorten the existing ban"
);
}
#[test]
fn rebanning_with_later_expiry_extends() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
let start = ts.now();
bl.ban(
contract,
start + Duration::from_secs(60),
BanReason::AutoMad,
);
bl.ban(
contract,
start + Duration::from_secs(120),
BanReason::AutoMad,
);
ts.advance_time(Duration::from_secs(90));
assert!(
bl.is_banned(&contract),
"extended ban window must keep contract banned past original expiry"
);
}
#[ignore = "superseded by #4303 cap: AutoMad now dominates Operator on re-ban (PR #4370)"]
#[test]
fn operator_reason_wins_over_auto() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
let now = ts.now();
bl.ban(contract, now + Duration::from_secs(60), BanReason::AutoMad);
bl.ban(contract, now + Duration::from_secs(60), BanReason::Operator);
bl.ban(contract, now + Duration::from_secs(60), BanReason::AutoMad);
assert!(bl.is_banned(&contract));
let entry = bl.entries.get(&contract).unwrap();
assert_eq!(entry.reason, BanReason::Operator);
}
#[test]
fn auto_reason_dominates_operator_on_reban() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
let now = ts.now();
bl.ban(contract, now + Duration::from_secs(60), BanReason::Operator);
bl.ban(contract, now + Duration::from_secs(60), BanReason::AutoMad);
assert!(bl.is_banned(&contract));
assert_eq!(
bl.entries.get(&contract).unwrap().reason,
BanReason::AutoMad,
"a reaper auto-ban must upgrade an existing operator entry to AutoMad"
);
bl.ban(contract, now + Duration::from_secs(60), BanReason::Operator);
assert_eq!(
bl.entries.get(&contract).unwrap().reason,
BanReason::AutoMad,
"an operator re-ban must never downgrade a security (AutoMad) ban"
);
}
#[test]
fn cleanup_drops_expired_entries() {
let (bl, ts) = mk_ban_list();
bl.ban(
mk_contract(1),
ts.now() + Duration::from_secs(10),
BanReason::AutoMad,
);
bl.ban(
mk_contract(2),
ts.now() + Duration::from_secs(60),
BanReason::AutoMad,
);
assert_eq!(bl.len(), 2);
ts.advance_time(Duration::from_secs(20));
bl.cleanup();
assert_eq!(
bl.len(),
1,
"cleanup must drop contract 1 (expired) but keep contract 2 (fresh)"
);
}
#[test]
fn distinct_contracts_independent() {
let (bl, ts) = mk_ban_list();
bl.ban(
mk_contract(1),
ts.now() + Duration::from_secs(60),
BanReason::AutoMad,
);
assert!(bl.is_banned(&mk_contract(1)));
assert!(!bl.is_banned(&mk_contract(2)));
assert_eq!(bl.len(), 1);
}
fn mk_ban_list_with_max(max: usize) -> (ContractBanList, SharedMockTimeSource) {
let ts = SharedMockTimeSource::new();
let bl = ContractBanList::with_max(Arc::new(ts.clone()), max);
(bl, ts)
}
#[test]
fn ban_returns_outcome() {
let (bl, ts) = mk_ban_list();
let c = mk_contract(1);
let exp = ts.now() + Duration::from_secs(60);
assert_eq!(bl.ban(c, exp, BanReason::AutoMad), BanOutcome::Banned);
assert_eq!(bl.ban(c, exp, BanReason::AutoMad), BanOutcome::Updated);
assert_eq!(bl.len(), 1);
}
#[test]
fn capacity_exceeded_when_cap_reached() {
let (bl, ts) = mk_ban_list_with_max(8);
let exp = ts.now() + Duration::from_secs(60);
for i in 0..8 {
assert_eq!(
bl.ban(mk_contract(i + 1), exp, BanReason::AutoMad),
BanOutcome::Banned,
"ban {i} below the cap must be admitted"
);
}
assert_eq!(bl.len(), 8);
assert_eq!(
bl.ban(mk_contract(99), exp, BanReason::AutoMad),
BanOutcome::CapacityExceeded,
"new contract past the cap must be CapacityExceeded"
);
assert_eq!(bl.len(), 8, "cap rejection must not grow the list");
assert_eq!(bl.capacity_rejected_total(), 1);
assert!(!bl.is_banned(&mk_contract(99)));
assert_eq!(
bl.ban(mk_contract(1), exp, BanReason::AutoMad),
BanOutcome::Updated,
"existing contract must keep working at the cap"
);
}
#[test]
fn operator_ban_rejected_at_cap_when_only_auto_present() {
let (bl, ts) = mk_ban_list_with_max(4);
let exp = ts.now() + Duration::from_secs(60);
for i in 0..4 {
bl.ban(mk_contract(i + 1), exp, BanReason::AutoMad);
}
assert_eq!(
bl.ban(mk_contract(50), exp, BanReason::Operator),
BanOutcome::CapacityExceeded,
"operator ban at cap must not displace reaper-driven bans"
);
assert!(!bl.is_banned(&mk_contract(50)));
assert_eq!(bl.len(), 4);
}
#[test]
fn auto_ban_evicts_operator_entry_at_cap() {
let (bl, ts) = mk_ban_list_with_max(4);
let exp = ts.now() + Duration::from_secs(60);
bl.ban(mk_contract(1), exp, BanReason::Operator);
bl.ban(mk_contract(2), exp, BanReason::AutoMad);
bl.ban(mk_contract(3), exp, BanReason::AutoMad);
bl.ban(mk_contract(4), exp, BanReason::AutoMad);
assert_eq!(bl.len(), 4);
assert_eq!(
bl.ban(mk_contract(5), exp, BanReason::AutoMad),
BanOutcome::Banned,
"reaper ban at cap must evict an operator entry and succeed"
);
assert!(bl.is_banned(&mk_contract(5)), "new reaper ban is active");
assert!(
!bl.is_banned(&mk_contract(1)),
"the operator entry must have been evicted to make room"
);
assert_eq!(bl.len(), 4);
assert_eq!(bl.capacity_rejected_total(), 0);
}
#[test]
fn auto_ban_rejected_at_cap_when_no_operator_to_evict() {
let (bl, ts) = mk_ban_list_with_max(3);
let exp = ts.now() + Duration::from_secs(60);
for i in 0..3 {
bl.ban(mk_contract(i + 1), exp, BanReason::AutoMad);
}
assert_eq!(
bl.ban(mk_contract(9), exp, BanReason::AutoMad),
BanOutcome::CapacityExceeded,
"reaper ban at a fully-reaper-driven cap has nothing to evict"
);
assert_eq!(bl.len(), 3);
assert_eq!(bl.capacity_rejected_total(), 1);
}
#[test]
fn unban_decrements_size_counter_freeing_a_slot() {
let (bl, ts) = mk_ban_list_with_max(2);
let exp = ts.now() + Duration::from_secs(60);
bl.ban(mk_contract(1), exp, BanReason::AutoMad);
bl.ban(mk_contract(2), exp, BanReason::AutoMad);
assert_eq!(
bl.ban(mk_contract(3), exp, BanReason::AutoMad),
BanOutcome::CapacityExceeded
);
bl.unban(&mk_contract(1));
assert_eq!(bl.len(), 1);
assert_eq!(
bl.ban(mk_contract(3), exp, BanReason::AutoMad),
BanOutcome::Banned,
"unban must free a slot for new bans (size counter decremented)"
);
assert_eq!(bl.len(), 2);
}
#[test]
fn unban_missing_contract_does_not_underflow_counter() {
let (bl, ts) = mk_ban_list_with_max(2);
let exp = ts.now() + Duration::from_secs(60);
bl.unban(&mk_contract(7));
bl.ban(mk_contract(1), exp, BanReason::AutoMad);
bl.ban(mk_contract(2), exp, BanReason::AutoMad);
assert_eq!(
bl.ban(mk_contract(3), exp, BanReason::AutoMad),
BanOutcome::CapacityExceeded,
"spurious unban must not corrupt the size counter"
);
}
#[test]
fn cleanup_decrements_size_counter_freeing_slots() {
let (bl, ts) = mk_ban_list_with_max(2);
let now = ts.now();
bl.ban(
mk_contract(1),
now + Duration::from_secs(10),
BanReason::AutoMad,
);
bl.ban(
mk_contract(2),
now + Duration::from_secs(60),
BanReason::AutoMad,
);
assert_eq!(
bl.ban(
mk_contract(3),
now + Duration::from_secs(60),
BanReason::AutoMad
),
BanOutcome::CapacityExceeded
);
ts.advance_time(Duration::from_secs(20));
bl.cleanup();
assert_eq!(bl.len(), 1, "cleanup drops the expired entry");
assert_eq!(
bl.ban(
mk_contract(3),
ts.now() + Duration::from_secs(60),
BanReason::AutoMad
),
BanOutcome::Banned,
"cleanup must free a slot for new bans (size counter decremented)"
);
assert_eq!(bl.len(), 2);
}
#[test]
fn concurrent_distinct_bans_do_not_overshoot_cap() {
use std::sync::{Arc as StdArc, Barrier};
use std::thread;
const CAP: usize = 8;
const THREADS: usize = 64;
let ts = SharedMockTimeSource::new();
let bl = StdArc::new(ContractBanList::with_max(Arc::new(ts.clone()), CAP));
let exp = ts.now() + Duration::from_secs(60);
let barrier = StdArc::new(Barrier::new(THREADS));
let mut handles = Vec::with_capacity(THREADS);
for i in 0..THREADS {
let bl = bl.clone();
let b = barrier.clone();
handles.push(thread::spawn(move || {
b.wait();
bl.ban(mk_contract((i + 1) as u8), exp, BanReason::AutoMad)
}));
}
let mut banned = 0;
let mut cap_rejected = 0;
let mut updated = 0;
for h in handles {
match h.join().unwrap() {
BanOutcome::Banned => banned += 1,
BanOutcome::CapacityExceeded => cap_rejected += 1,
BanOutcome::Updated => updated += 1,
}
}
assert_eq!(
bl.len(),
CAP,
"strict cap: list size must equal CAP after a 64-thread flood, got {}",
bl.len()
);
assert_eq!(banned, CAP, "exactly CAP bans admitted, got {banned}");
assert_eq!(cap_rejected, THREADS - CAP);
assert_eq!(updated, 0, "all keys distinct, no Updated outcomes");
assert_eq!(bl.capacity_rejected_total(), (THREADS - CAP) as u64);
}
#[test]
fn ban_decision_adds_to_list() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(7);
let now = ts.now();
let decisions = vec![mk_decision(contract, TransitionReason::BanTriggered, now)];
Ring::apply_ban_decisions(&bl, &decisions, now + Duration::from_secs(60));
assert!(
bl.is_banned(&contract),
"BanTriggered decision must add the contract to the ban list"
);
}
#[test]
fn lifted_decision_removes_from_list() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(7);
bl.ban(
contract,
ts.now() + Duration::from_secs(60),
BanReason::AutoMad,
);
let decisions = vec![mk_decision(contract, TransitionReason::BanLifted, ts.now())];
Ring::apply_ban_decisions(&bl, &decisions, ts.now() + Duration::from_secs(60));
assert!(
!bl.is_banned(&contract),
"BanLifted decision must remove the contract from the ban list"
);
}
#[test]
fn unrelated_decisions_do_not_touch_ban_list() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(7);
let decisions = vec![mk_decision(contract, TransitionReason::Evicted, ts.now())];
Ring::apply_ban_decisions(&bl, &decisions, ts.now() + Duration::from_secs(60));
assert!(
!bl.is_banned(&contract),
"non-ban transition reasons must not affect the ban list"
);
assert_eq!(bl.len(), 0);
}
#[test]
fn non_actionable_decision_is_skipped() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(9);
let mut decision = mk_decision(contract, TransitionReason::BanTriggered, ts.now());
decision.actionable = false;
Ring::apply_ban_decisions(&bl, &[decision], ts.now() + Duration::from_secs(60));
assert!(
!bl.is_banned(&contract),
"non-actionable BanTriggered must not land on the ban list"
);
bl.ban(
contract,
ts.now() + Duration::from_secs(60),
BanReason::AutoMad,
);
let mut decision = mk_decision(contract, TransitionReason::BanLifted, ts.now());
decision.actionable = false;
Ring::apply_ban_decisions(&bl, &[decision], ts.now() + Duration::from_secs(60));
assert!(
bl.is_banned(&contract),
"non-actionable BanLifted must not remove an existing ban"
);
}
#[test]
fn batch_mixed_decisions_apply_in_order() {
let (bl, ts) = mk_ban_list();
let a = mk_contract(1);
let b = mk_contract(2);
let c = mk_contract(3);
bl.ban(b, ts.now() + Duration::from_secs(60), BanReason::AutoMad);
let now = ts.now();
let decisions = vec![
mk_decision(a, TransitionReason::BanTriggered, now),
mk_decision(b, TransitionReason::BanLifted, now),
mk_decision(c, TransitionReason::BanTriggered, now),
];
Ring::apply_ban_decisions(&bl, &decisions, now + Duration::from_secs(60));
assert!(bl.is_banned(&a), "contract A must be newly banned");
assert!(!bl.is_banned(&b), "contract B must be unbanned");
assert!(bl.is_banned(&c), "contract C must be newly banned");
}
fn dispatch_block<'a>(src: &'a str, arm_header: &str) -> &'a str {
let start = src
.find(arm_header)
.unwrap_or_else(|| panic!("could not locate `{arm_header}` in node.rs"));
let tail = &src[start + 1..];
let len = tail
.find("\n NetMessageV1::")
.or_else(|| tail.find("\n NetMessageV1::"))
.unwrap_or(tail.len());
&src[start..start + 1 + len]
}
#[test]
fn put_dispatch_gates_banned_contracts() {
const NODE_SRC: &str = include_str!("../node.rs");
let block = dispatch_block(NODE_SRC, "NetMessageV1::Put(ref op) =>");
let gate_pos = block
.find("contract_ban_list.is_banned")
.expect("PUT dispatch is missing the ban-list gate");
let spawn_pos = block
.find("start_relay_put")
.expect("PUT dispatch is missing start_relay_put");
assert!(
gate_pos < spawn_pos,
"PUT ban-list gate (offset {gate_pos}) must run BEFORE \
start_relay_put (offset {spawn_pos}) so banned requests \
don't pay the spawn cost"
);
for variant in ["PutMsg::Request {", "PutMsg::RequestStreaming {"] {
assert!(
block.contains(variant),
"PUT dispatch block is missing wire variant `{variant}`"
);
}
}
#[test]
fn get_dispatch_gates_banned_contracts() {
const NODE_SRC: &str = include_str!("../node.rs");
let block = dispatch_block(NODE_SRC, "NetMessageV1::Get(ref op) =>");
let gate_pos = block
.find("contract_ban_list.is_banned")
.expect("GET dispatch is missing the ban-list gate");
let spawn_pos = block
.find("start_relay_get")
.expect("GET dispatch is missing start_relay_get");
assert!(
gate_pos < spawn_pos,
"GET ban-list gate (offset {gate_pos}) must run BEFORE \
start_relay_get (offset {spawn_pos})"
);
assert!(
block.contains("GetMsg::Request {"),
"GET dispatch block is missing wire variant `GetMsg::Request {{`"
);
}
#[test]
fn update_dispatch_gates_banned_contracts() {
const NODE_SRC: &str = include_str!("../node.rs");
let block = dispatch_block(NODE_SRC, "NetMessageV1::Update(ref op) =>");
let gate_pos = block
.find("contract_ban_list.is_banned")
.expect("UPDATE dispatch is missing the ban-list gate");
let rate_limit_pos = block
.find("update_rate_limiter")
.expect("UPDATE dispatch is missing the rate limiter");
assert!(
gate_pos < rate_limit_pos,
"UPDATE ban-list gate (offset {gate_pos}) must run BEFORE \
the rate limiter (offset {rate_limit_pos}) so banned \
traffic doesn't consume the rate-limit budget"
);
for variant in [
"UpdateMsg::RequestUpdate {",
"UpdateMsg::BroadcastTo {",
"UpdateMsg::RequestUpdateStreaming {",
"UpdateMsg::BroadcastToStreaming {",
] {
assert!(
block.contains(variant),
"UPDATE dispatch block is missing wire variant `{variant}`"
);
}
}
#[test]
fn subscribe_dispatch_gates_banned_contracts() {
const NODE_SRC: &str = include_str!("../node.rs");
let block = dispatch_block(NODE_SRC, "NetMessageV1::Subscribe(ref op) =>");
let gate_pos = block
.find("contract_ban_list.is_banned")
.expect("SUBSCRIBE dispatch is missing the ban-list gate");
let driver_pos = block
.find("start_relay_subscribe(")
.expect("SUBSCRIBE dispatch is missing start_relay_subscribe call");
assert!(
gate_pos < driver_pos,
"SUBSCRIBE ban-list gate (offset {gate_pos}) must run \
BEFORE start_relay_subscribe call (offset {driver_pos})"
);
assert!(
block.contains("SubscribeMsg::Request {"),
"SUBSCRIBE dispatch block is missing `SubscribeMsg::Request {{`"
);
}
#[test]
fn neighbor_hosting_gates_banned_egress() {
const NODE_SRC: &str = include_str!("../node.rs");
let block = dispatch_block(NODE_SRC, "NetMessageV1::NeighborHosting");
let gate_pos = block.find("contract_ban_list.is_banned").expect(
"NeighborHosting overlap-sync block is missing the ban-list egress gate — \
banned contracts would continue to be pushed to sibling peers",
);
let emit_pos = block
.find("NodeEvent::SyncStateToPeer")
.expect("NeighborHosting block is missing SyncStateToPeer emit");
assert!(
gate_pos < emit_pos,
"NeighborHosting ban-list gate (offset {gate_pos}) must \
precede SyncStateToPeer emit (offset {emit_pos})"
);
}
#[test]
fn interest_sync_summaries_gates_banned_egress() {
const NODE_SRC: &str = include_str!("../node.rs");
let stale_loop = NODE_SRC
.find("for contract in stale_contracts")
.expect("node.rs is missing the stale_contracts loop");
let scan = &NODE_SRC[stale_loop..stale_loop.saturating_add(2_000).min(NODE_SRC.len())];
let gate_pos = scan.find("contract_ban_list.is_banned").expect(
"stale-summary repair loop is missing the ban-list egress gate — \
banned contracts would continue to be pushed to peers that report \
stale summaries (defeats the Phase 7 wire-boundary drop)",
);
let emit_pos = scan
.find("NodeEvent::SyncStateToPeer")
.expect("stale-summary loop is missing SyncStateToPeer emit");
assert!(
gate_pos < emit_pos,
"InterestSync ban-list gate (offset {gate_pos}) must \
precede SyncStateToPeer emit (offset {emit_pos})"
);
}
#[test]
fn snapshot_of_empty_list_is_empty() {
let (bl, _ts) = mk_ban_list();
assert!(bl.snapshot().is_empty());
}
#[test]
fn snapshot_reports_key_reason_and_remaining() {
let (bl, ts) = mk_ban_list();
let auto = mk_contract(1);
let operator = mk_contract(2);
let now = ts.now();
bl.ban(auto, now + Duration::from_secs(120), BanReason::AutoMad);
bl.ban(
operator,
now + Duration::from_secs(300),
BanReason::Operator,
);
let snap = bl.snapshot();
assert_eq!(snap.len(), 2, "both bans must appear in the snapshot");
let auto_row = snap
.iter()
.find(|e| e.contract == auto)
.expect("auto-banned contract must be in snapshot");
assert_eq!(auto_row.reason, BanReason::AutoMad);
assert_eq!(auto_row.remaining, Duration::from_secs(120));
let op_row = snap
.iter()
.find(|e| e.contract == operator)
.expect("operator-banned contract must be in snapshot");
assert_eq!(op_row.reason, BanReason::Operator);
assert_eq!(op_row.remaining, Duration::from_secs(300));
}
#[test]
fn snapshot_remaining_shrinks_as_time_advances() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
let now = ts.now();
bl.ban(contract, now + Duration::from_secs(100), BanReason::AutoMad);
ts.advance_time(Duration::from_secs(40));
let snap = bl.snapshot();
assert_eq!(snap.len(), 1);
assert_eq!(
snap[0].remaining,
Duration::from_secs(60),
"remaining must reflect elapsed time"
);
}
#[test]
fn snapshot_excludes_expired_but_unswept_entries() {
let (bl, ts) = mk_ban_list();
let contract = mk_contract(1);
let now = ts.now();
bl.ban(contract, now + Duration::from_secs(30), BanReason::AutoMad);
ts.advance_time(Duration::from_secs(31));
assert!(
!bl.is_banned(&contract),
"guard: entry is logically expired"
);
assert!(
bl.snapshot().is_empty(),
"expired-but-unswept entry must not appear in the snapshot"
);
}
#[test]
fn dashboard_count_derives_from_live_entries_not_len() {
const RING_SRC: &str = include_str!("../ring.rs");
let fn_pos = RING_SRC
.find("pub fn dashboard_ban_list_snapshot")
.expect("dashboard_ban_list_snapshot must exist in ring.rs");
let body = &RING_SRC[fn_pos..fn_pos.saturating_add(2_000).min(RING_SRC.len())];
assert!(
body.contains("count: entries.len()"),
"dashboard count must be derived from the filtered live \
`entries`, not the raw ban-list `len()` — see Codex review \
on #4464:\n{body}"
);
assert!(
!body.contains("count: self.contract_ban_list.len()"),
"dashboard count must NOT use `contract_ban_list.len()` — it \
includes expired-but-unswept entries the entry list omits"
);
}
}