chat: roster-bound authorship, replay dedup, and rate limits (Phase 2)
CI / check (push) Successful in 2m59s
CI / check (push) Successful in 2m59s
Chat-hardening plan Phase 2 — only current authenticated room members can create chat UI work, impersonation via the wire name is structurally closed, and no member can monopolize the event channel: - core: new ChatRoster (bounded id -> sanitized-name map, shared) replaces the event task's bare HashSet; upserted on PeerJoined/PeerUpdated, removed on graceful PeerLeft AND terminal grace-expiry eviction (both timer paths). Non-roster chat is dropped before attachment handling; the rendered author label is the roster-bound name — the sender-claimed wire name is never read. - gossip: ChatIngressGate after verify_gossip, before any sanitize work or event send: early known-author gate (live + mid-reconnect peers), exact- replay suppression keyed on the deterministic Ed25519 signature (1024-entry cap + freshness-window TTL, zero new deps vs the plan's BLAKE3 option), then per-author (8 burst, 1/s) and room-wide (32 burst, 8/s) token buckets. Replays are detected before tokens are consumed; a room-bucket reject refunds the author token; rejection logging is squelched per author. - The inner Chat.ts is now ignored entirely; RoomEvent carries the signed envelope timestamp. 550 lib tests (+18), reconnect_eviction +1 (grace keeps chat authority, terminal eviction revokes it), clippy --all-targets -D warnings clean. Tests-green-only: the plan's two-machine field-test section remains open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+518
-3
@@ -36,7 +36,8 @@ const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;
|
||||
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
|
||||
/// `author` can't validate (the attacker lacks the victim's secret key). `ts`
|
||||
/// (sender-stamped unix-millis) is covered by the signature and gates replay
|
||||
/// freshness — distinct from `GossipMessage::Chat.ts`, which is only for display.
|
||||
/// freshness — distinct from `GossipMessage::Chat.ts`, an unauthenticated
|
||||
/// duplicate kept only for wire compatibility and ignored on receive.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct GossipPayload {
|
||||
pub author: EndpointId,
|
||||
@@ -254,6 +255,239 @@ impl ClockSkewMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat-hardening Phase 2 policy (docs/chat-hardening-plan.md): exact-replay
|
||||
/// suppression bounds and the token-bucket rates that stop one admitted member
|
||||
/// from monopolizing the event channel / UI with chat.
|
||||
///
|
||||
/// The replay cache is keyed on the payload's Ed25519 SIGNATURE bytes rather
|
||||
/// than a separate BLAKE3 digest: ed25519 signing is deterministic (RFC 8032),
|
||||
/// so the 64-byte signature is itself a collision-resistant fingerprint of the
|
||||
/// exact signed bytes (topic + author + ts + msg) — same dedup power, zero new
|
||||
/// dependencies. Entries are stamped with the payload's SIGNED timestamp and
|
||||
/// pruned once that falls out of the freshness window, because `verify_gossip`
|
||||
/// already rejects any replay whose signed `ts` is out-of-window — an expired
|
||||
/// cache entry can no longer correspond to an admissible frame.
|
||||
const CHAT_REPLAY_CACHE_CAP: usize = 1024;
|
||||
|
||||
/// Per-author chat budget: a burst of 8 absorbs a fast typist; 1 msg/s sustained
|
||||
/// is well above real human chat rate while bounding a flooder to a trickle.
|
||||
const CHAT_AUTHOR_BURST: f64 = 8.0;
|
||||
const CHAT_AUTHOR_REFILL_PER_MS: f64 = 1.0 / 1000.0;
|
||||
|
||||
/// Room-wide chat budget across ALL authors, so a set of sock-puppet identities
|
||||
/// can't multiply the per-author budget into unbounded event-channel pressure.
|
||||
const CHAT_ROOM_BURST: f64 = 32.0;
|
||||
const CHAT_ROOM_REFILL_PER_MS: f64 = 8.0 / 1000.0;
|
||||
|
||||
/// Bound on the per-author bucket map. Authors only enter it after the
|
||||
/// known-author gate, so it tracks roughly the live roster plus recently
|
||||
/// disconnected members; idle entries are pruned past this cap.
|
||||
const CHAT_AUTHOR_BUCKETS_CAP: usize = 64;
|
||||
|
||||
/// Cooldown between logged chat rejections for one author (and one shared slot
|
||||
/// for unknown authors), so a flood of rejected frames can't turn the log into
|
||||
/// the new unbounded cost.
|
||||
const CHAT_REJECT_LOG_COOLDOWN_MS: u64 = 10_000;
|
||||
|
||||
/// A minimal deterministic token bucket: time is passed in, never read from a
|
||||
/// clock, so every boundary is unit-testable.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TokenBucket {
|
||||
tokens: f64,
|
||||
last_ms: u64,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
fn full(burst: f64, now_ms: u64) -> Self {
|
||||
Self {
|
||||
tokens: burst,
|
||||
last_ms: now_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refill for elapsed time (capped at `burst`), then take one token if
|
||||
/// available. Returns whether a token was consumed.
|
||||
fn try_take(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) -> bool {
|
||||
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
|
||||
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
|
||||
self.last_ms = now_ms;
|
||||
if self.tokens >= 1.0 {
|
||||
self.tokens -= 1.0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an authenticated chat payload was still refused admission (Phase 2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ChatReject {
|
||||
/// Author is neither a live gossip peer nor one mid-reconnect. The core
|
||||
/// roster gate re-checks this as the final authority; this early copy just
|
||||
/// refuses the work before any sanitize/attachment handling.
|
||||
UnknownAuthor,
|
||||
/// Exact byte-for-byte replay of an already-admitted signed chat.
|
||||
Replay,
|
||||
/// Per-author or room-wide token bucket empty.
|
||||
RateLimited,
|
||||
}
|
||||
|
||||
/// Per-author rate-limit + log-squelch state (see [`ChatIngressGate`]).
|
||||
#[derive(Debug)]
|
||||
struct AuthorGateState {
|
||||
bucket: TokenBucket,
|
||||
last_seen_ms: u64,
|
||||
last_reject_log_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Chat admission gate run after `verify_gossip`, before any sanitize work or
|
||||
/// event-channel send (chat-hardening plan Phase 2): known author → exact-replay
|
||||
/// dedup → per-author + room token buckets, in that order. Dedup runs BEFORE the
|
||||
/// buckets so a replayed frame can never consume tokens and starve the author's
|
||||
/// own legitimate next message. Pure — callers pass `now_ms` — so every branch
|
||||
/// is unit-testable.
|
||||
#[derive(Debug)]
|
||||
struct ChatIngressGate {
|
||||
seen: HashSet<[u8; 64]>,
|
||||
/// FIFO of (signature, signed ts) mirroring `seen`, for TTL + cap pruning.
|
||||
seen_order: std::collections::VecDeque<([u8; 64], u64)>,
|
||||
room: TokenBucket,
|
||||
authors: HashMap<EndpointId, AuthorGateState>,
|
||||
/// Shared squelch slot for unknown-author rejects (they have no map entry).
|
||||
last_unknown_log_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl ChatIngressGate {
|
||||
fn new(now_ms: u64) -> Self {
|
||||
Self {
|
||||
seen: HashSet::new(),
|
||||
seen_order: std::collections::VecDeque::new(),
|
||||
room: TokenBucket::full(CHAT_ROOM_BURST, now_ms),
|
||||
authors: HashMap::new(),
|
||||
last_unknown_log_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit or reject one verified chat payload. `known_author` is the caller's
|
||||
/// live-or-reconnecting membership check; `payload_ts` is the SIGNED envelope
|
||||
/// timestamp (already freshness-checked by `verify_gossip`).
|
||||
fn admit(
|
||||
&mut self,
|
||||
known_author: bool,
|
||||
author: EndpointId,
|
||||
sig: &[u8; 64],
|
||||
payload_ts: u64,
|
||||
now_ms: u64,
|
||||
) -> Result<(), ChatReject> {
|
||||
if !known_author {
|
||||
return Err(ChatReject::UnknownAuthor);
|
||||
}
|
||||
self.prune_replay_cache(now_ms);
|
||||
if self.seen.contains(sig) {
|
||||
return Err(ChatReject::Replay);
|
||||
}
|
||||
// Room bucket first: it is the cheaper aggregate bound, and consuming
|
||||
// from it only when the author bucket also admits keeps the two in
|
||||
// lockstep — so check both, then commit both.
|
||||
let author_state = self.author_entry(author, now_ms);
|
||||
let author_ok =
|
||||
author_state
|
||||
.bucket
|
||||
.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms);
|
||||
if !author_ok {
|
||||
return Err(ChatReject::RateLimited);
|
||||
}
|
||||
if !self
|
||||
.room
|
||||
.try_take(CHAT_ROOM_BURST, CHAT_ROOM_REFILL_PER_MS, now_ms)
|
||||
{
|
||||
// Refund the author token so a room-wide squeeze doesn't also debit
|
||||
// every individual author's future budget.
|
||||
if let Some(state) = self.authors.get_mut(&author) {
|
||||
state.bucket.tokens = (state.bucket.tokens + 1.0).min(CHAT_AUTHOR_BURST);
|
||||
}
|
||||
return Err(ChatReject::RateLimited);
|
||||
}
|
||||
// Fully admitted — only now does the frame enter the replay cache, so a
|
||||
// rate-limited legitimate message redelivered later is not misread as a
|
||||
// replay of something that was never displayed.
|
||||
self.seen.insert(*sig);
|
||||
self.seen_order.push_back((*sig, payload_ts));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether this rejection should be logged: at most one log line per author
|
||||
/// (or one shared line for unknown authors) per cooldown window.
|
||||
fn should_log_reject(&mut self, known_author: bool, author: EndpointId, now_ms: u64) -> bool {
|
||||
let slot = if known_author {
|
||||
self.authors
|
||||
.get_mut(&author)
|
||||
.map(|state| &mut state.last_reject_log_ms)
|
||||
} else {
|
||||
Some(&mut self.last_unknown_log_ms)
|
||||
};
|
||||
let Some(slot) = slot else {
|
||||
return true;
|
||||
};
|
||||
let due =
|
||||
slot.is_none_or(|last| now_ms.saturating_sub(last) >= CHAT_REJECT_LOG_COOLDOWN_MS);
|
||||
if due {
|
||||
*slot = Some(now_ms);
|
||||
}
|
||||
due
|
||||
}
|
||||
|
||||
/// Drop cache entries whose signed timestamp fell out of the freshness
|
||||
/// window (they can no longer pass `verify_gossip`), then enforce the hard
|
||||
/// cap FIFO-oldest-first.
|
||||
fn prune_replay_cache(&mut self, now_ms: u64) {
|
||||
let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
|
||||
while let Some((sig, ts)) = self.seen_order.front() {
|
||||
if *ts >= floor && self.seen_order.len() < CHAT_REPLAY_CACHE_CAP {
|
||||
break;
|
||||
}
|
||||
self.seen.remove(sig);
|
||||
self.seen_order.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get-or-create the author's bucket state, pruning the map if a flood of
|
||||
/// short-lived identities has grown it past its cap: idle authors (nothing
|
||||
/// admitted within the freshness window) go first, then oldest-seen.
|
||||
fn author_entry(&mut self, author: EndpointId, now_ms: u64) -> &mut AuthorGateState {
|
||||
if !self.authors.contains_key(&author) && self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
|
||||
let floor = now_ms.saturating_sub(GOSSIP_FRESHNESS_MS);
|
||||
self.authors.retain(|_, state| state.last_seen_ms >= floor);
|
||||
while self.authors.len() >= CHAT_AUTHOR_BUCKETS_CAP {
|
||||
if let Some(oldest) = self
|
||||
.authors
|
||||
.iter()
|
||||
.min_by_key(|(_, state)| state.last_seen_ms)
|
||||
.map(|(id, _)| *id)
|
||||
{
|
||||
self.authors.remove(&oldest);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let state = self.authors.entry(author).or_insert(AuthorGateState {
|
||||
bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
|
||||
last_seen_ms: now_ms,
|
||||
last_reject_log_ms: None,
|
||||
});
|
||||
state.last_seen_ms = now_ms;
|
||||
state
|
||||
}
|
||||
|
||||
/// Drop an author's limiter state alongside its roster eviction (a signed
|
||||
/// `Leave`), so the map stays bounded by the roster's own churn.
|
||||
fn evict_author(&mut self, author: &EndpointId) {
|
||||
self.authors.remove(author);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of distinct peers we hold in a room roster at once.
|
||||
///
|
||||
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
||||
@@ -524,6 +758,7 @@ impl RoomState for IrohGossipState {
|
||||
));
|
||||
let mut state_mutations_seen = HashMap::new();
|
||||
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
||||
let mut chat_gate = ChatIngressGate::new(now_millis());
|
||||
|
||||
// Broadcast initial state
|
||||
let initial_payload = {
|
||||
@@ -793,6 +1028,10 @@ impl RoomState for IrohGossipState {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&payload.author);
|
||||
// Roster eviction also drops the author's
|
||||
// chat-limiter state, keeping that map
|
||||
// bounded by roster churn (Phase 2).
|
||||
chat_gate.evict_author(&payload.author);
|
||||
if removed || was_disconnected {
|
||||
let _ = event_tx
|
||||
.send(RoomEvent::PeerLeft(payload.author))
|
||||
@@ -802,9 +1041,43 @@ impl RoomState for IrohGossipState {
|
||||
GossipMessage::Chat {
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
// The inner ts is an unauthenticated duplicate of the
|
||||
// signed envelope ts — ignored entirely; the envelope
|
||||
// value is what RoomEvent carries (Phase 2).
|
||||
ts: _,
|
||||
attachment,
|
||||
} => {
|
||||
// Phase 2 ingress admission, BEFORE any sanitize or
|
||||
// attachment work: known author (live or
|
||||
// mid-reconnect — the core roster gate is the final
|
||||
// authority) → exact-replay dedup keyed on the
|
||||
// signature → per-author + room token buckets.
|
||||
let known_author =
|
||||
peers.lock().unwrap().contains_key(&payload.author)
|
||||
|| disconnected_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&payload.author);
|
||||
if let Err(reject) = chat_gate.admit(
|
||||
known_author,
|
||||
payload.author,
|
||||
&payload.sig.to_bytes(),
|
||||
payload.ts,
|
||||
received_now_ms,
|
||||
) {
|
||||
if chat_gate.should_log_reject(
|
||||
known_author,
|
||||
payload.author,
|
||||
received_now_ms,
|
||||
) {
|
||||
crate::log_msg(&format!(
|
||||
"Dropped chat from author={}: {:?}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
reject
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
crate::log_msg(&format!(
|
||||
"Gossip chat from author={:?}",
|
||||
payload.author
|
||||
@@ -839,7 +1112,10 @@ impl RoomState for IrohGossipState {
|
||||
from: payload.author,
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
// Only the SIGNED envelope timestamp travels
|
||||
// downstream (never used for replay/ordering —
|
||||
// the gate above already handled replay).
|
||||
ts: payload.ts,
|
||||
attachment,
|
||||
})
|
||||
.await;
|
||||
@@ -1687,4 +1963,243 @@ mod tests {
|
||||
5
|
||||
));
|
||||
}
|
||||
|
||||
// ---- Chat-hardening Phase 2: ingress gate (replay dedup + token buckets) ----
|
||||
|
||||
/// Distinct opaque "signature" bytes; the gate never inspects them beyond
|
||||
/// equality, so a counter-stamped array stands in for a real signature.
|
||||
fn sig(n: u64) -> [u8; 64] {
|
||||
let mut bytes = [0u8; 64];
|
||||
bytes[..8].copy_from_slice(&n.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
const T0: u64 = 1_000_000_000_000;
|
||||
|
||||
#[test]
|
||||
fn token_bucket_burst_and_refill_boundaries() {
|
||||
let mut bucket = TokenBucket::full(CHAT_AUTHOR_BURST, T0);
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
|
||||
}
|
||||
// Burst exhausted at the same instant.
|
||||
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0));
|
||||
// 999 ms refills just under one token at 1/s…
|
||||
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 999));
|
||||
// …a full second refills exactly one (999 ms already banked 0.999 of it,
|
||||
// so take at the accumulated boundary).
|
||||
assert!(bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
|
||||
assert!(!bucket.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, T0 + 1_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_rejects_unknown_author() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
assert_eq!(
|
||||
gate.admit(false, author, &sig(1), T0, T0),
|
||||
Err(ChatReject::UnknownAuthor)
|
||||
);
|
||||
// Same frame from a known author is fine — nothing was consumed above.
|
||||
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_suppresses_exact_replay_but_admits_distinct_same_ms() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
|
||||
// Two DISTINCT chats signed in the same millisecond both land…
|
||||
assert_eq!(gate.admit(true, author, &sig(2), T0, T0), Ok(()));
|
||||
// …but the byte-identical frame is a replay, from any deliverer.
|
||||
assert_eq!(
|
||||
gate.admit(true, author, &sig(1), T0, T0),
|
||||
Err(ChatReject::Replay)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_author_burst_then_refill() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
for n in 0..CHAT_AUTHOR_BURST as u64 {
|
||||
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
|
||||
}
|
||||
assert_eq!(
|
||||
gate.admit(true, author, &sig(99), T0, T0),
|
||||
Err(ChatReject::RateLimited)
|
||||
);
|
||||
// One second later the author has exactly one more message.
|
||||
assert_eq!(gate.admit(true, author, &sig(100), T0, T0 + 1_000), Ok(()));
|
||||
assert_eq!(
|
||||
gate.admit(true, author, &sig(101), T0, T0 + 1_000),
|
||||
Err(ChatReject::RateLimited)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_replay_never_consumes_tokens() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
for n in 0..CHAT_AUTHOR_BURST as u64 {
|
||||
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
|
||||
}
|
||||
// Replays of an admitted frame while exhausted report Replay (dedup runs
|
||||
// BEFORE the buckets) and burn no tokens…
|
||||
for _ in 0..50 {
|
||||
assert_eq!(
|
||||
gate.admit(true, author, &sig(0), T0, T0 + 1_000),
|
||||
Err(ChatReject::Replay)
|
||||
);
|
||||
}
|
||||
// …so the token refilled at +1s is still there for a NEW message.
|
||||
assert_eq!(gate.admit(true, author, &sig(200), T0, T0 + 1_000), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_rate_limited_frame_is_not_marked_replayed() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
for n in 0..CHAT_AUTHOR_BURST as u64 {
|
||||
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
|
||||
}
|
||||
// Rejected for rate only — NOT entered into the replay cache…
|
||||
assert_eq!(
|
||||
gate.admit(true, author, &sig(300), T0, T0),
|
||||
Err(ChatReject::RateLimited)
|
||||
);
|
||||
// …so the same signed frame redelivered after refill is admitted once.
|
||||
assert_eq!(gate.admit(true, author, &sig(300), T0, T0 + 1_000), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_room_bucket_bounds_sock_puppet_authors() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
// 40 distinct authors, one message each, same instant: per-author buckets
|
||||
// are all full, so only the room-wide burst bounds admission.
|
||||
let mut admitted = 0;
|
||||
for n in 0..40u64 {
|
||||
if gate.admit(true, fresh_id(), &sig(n), T0, T0).is_ok() {
|
||||
admitted += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(admitted, CHAT_ROOM_BURST as usize);
|
||||
// The room refills at 8/s: exactly 8 more land a second later, even from
|
||||
// fresh authors whose own buckets are full — the room bound decides.
|
||||
let mut late_admitted = 0;
|
||||
for n in 100..120u64 {
|
||||
if gate
|
||||
.admit(true, fresh_id(), &sig(n), T0, T0 + 1_000)
|
||||
.is_ok()
|
||||
{
|
||||
late_admitted += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(late_admitted, 8);
|
||||
// Chat admission being room-bounded is what keeps the event channel
|
||||
// available for control messages: an Announce is gated independently.
|
||||
let mut seen = HashMap::new();
|
||||
assert!(admit_state_mutation(
|
||||
&mut seen,
|
||||
fresh_id(),
|
||||
&GossipMessage::Leave,
|
||||
T0 + 1_000
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_room_reject_refunds_the_author_token() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
// Author A drains the whole room burst alone? No — its own burst is 8.
|
||||
// Use 4 authors × 8 to empty the room exactly.
|
||||
let mut n = 0u64;
|
||||
for _ in 0..4 {
|
||||
let author = fresh_id();
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
assert_eq!(gate.admit(true, author, &sig(n), T0, T0), Ok(()));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
// A 5th author is room-rejected 8 times, but its own bucket is refunded
|
||||
// each time…
|
||||
let victim = fresh_id();
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
assert_eq!(
|
||||
gate.admit(true, victim, &sig(n), T0, T0),
|
||||
Err(ChatReject::RateLimited)
|
||||
);
|
||||
n += 1;
|
||||
}
|
||||
// …so when the room refills, the victim still has its FULL burst.
|
||||
let mut admitted = 0;
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
if gate.admit(true, victim, &sig(n), T0, T0 + 1_000).is_ok() {
|
||||
admitted += 1;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
assert_eq!(admitted, CHAT_AUTHOR_BURST as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_replay_cache_prunes_by_ttl_and_cap() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
// TTL: an admitted frame's entry is dropped once its SIGNED ts falls out
|
||||
// of the freshness window (it could no longer pass verify_gossip anyway).
|
||||
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
|
||||
assert_eq!(gate.seen.len(), 1);
|
||||
gate.prune_replay_cache(T0 + GOSSIP_FRESHNESS_MS + 1);
|
||||
assert!(gate.seen.is_empty() && gate.seen_order.is_empty());
|
||||
|
||||
// Hard cap: stuff the cache directly (admission itself is rate-limited
|
||||
// far below the cap) and verify FIFO-oldest eviction bounds it.
|
||||
for n in 0..(CHAT_REPLAY_CACHE_CAP as u64 + 100) {
|
||||
gate.seen.insert(sig(n));
|
||||
gate.seen_order.push_back((sig(n), T0));
|
||||
}
|
||||
gate.prune_replay_cache(T0);
|
||||
assert!(gate.seen_order.len() < CHAT_REPLAY_CACHE_CAP);
|
||||
assert_eq!(gate.seen.len(), gate.seen_order.len());
|
||||
// The oldest entries went first.
|
||||
assert!(!gate.seen.contains(&sig(0)));
|
||||
assert!(gate.seen.contains(&sig(CHAT_REPLAY_CACHE_CAP as u64 + 99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_author_bucket_map_stays_bounded() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
for n in 0..(CHAT_AUTHOR_BUCKETS_CAP as u64 * 2) {
|
||||
let _ = gate.admit(true, fresh_id(), &sig(n), T0, T0 + n);
|
||||
}
|
||||
assert!(gate.authors.len() <= CHAT_AUTHOR_BUCKETS_CAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_evict_author_drops_limiter_state() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
assert_eq!(gate.admit(true, author, &sig(1), T0, T0), Ok(()));
|
||||
assert!(gate.authors.contains_key(&author));
|
||||
gate.evict_author(&author);
|
||||
assert!(!gate.authors.contains_key(&author));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_gate_reject_logging_is_squelched_per_author() {
|
||||
let mut gate = ChatIngressGate::new(T0);
|
||||
let author = fresh_id();
|
||||
// Establish limiter state, then exhaust it.
|
||||
for n in 0..=CHAT_AUTHOR_BURST as u64 {
|
||||
let _ = gate.admit(true, author, &sig(n), T0, T0);
|
||||
}
|
||||
assert!(gate.should_log_reject(true, author, T0));
|
||||
assert!(!gate.should_log_reject(true, author, T0 + 1));
|
||||
assert!(gate.should_log_reject(true, author, T0 + CHAT_REJECT_LOG_COOLDOWN_MS));
|
||||
|
||||
// Unknown authors share one squelch slot (they have no map entry).
|
||||
let stranger = fresh_id();
|
||||
assert!(gate.should_log_reject(false, stranger, T0));
|
||||
assert!(!gate.should_log_reject(false, fresh_id(), T0 + 1));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user