use std::sync::Arc;
use dashmap::DashMap;
use freenet_stdlib::prelude::ContractInstanceId;
use tokio::time::Instant;
use crate::util::time_source::TimeSource;
#[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,
}
pub(crate) struct ContractBanList {
entries: DashMap<ContractInstanceId, BanEntry>,
time_source: Arc<dyn TimeSource + Send + Sync>,
}
impl ContractBanList {
pub fn new(time_source: Arc<dyn TimeSource + Send + Sync>) -> Self {
Self {
entries: DashMap::new(),
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) {
use dashmap::mapref::entry::Entry;
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::Operator) {
cur.reason = BanReason::Operator;
}
}
Entry::Vacant(e) => {
e.insert(BanEntry { expires_at, reason });
}
}
}
pub fn unban(&self, contract: &ContractInstanceId) {
self.entries.remove(contract);
}
pub fn cleanup(&self) {
let now = self.time_source.now();
self.entries.retain(|_, entry| entry.expires_at > now);
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn len(&self) -> usize {
self.entries.len()
}
}
#[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"
);
}
#[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 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);
}
#[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})"
);
}
}