From 018962208564c4c939b586138c141c6c556c7a1c Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 12 Sep 2026 12:23:42 +0200 Subject: [PATCH] fix(peer): bound aggregate remote library state Per-peer library limits still allowed 64 authenticated identities to retain a large aggregate map and repeatedly clone it for UI publication. Cap retained remote availability at 16,384 rows. A peer whose new projection would exceed the budget stays authenticated with an empty library slice, and its accepted revision is retained so liveness does not repeatedly pull the same over-budget snapshot. Test Plan: - `just test` -- passed outside the sandbox; 496 peer tests and all workspace targets passed. - `just fmt` -- Rust formatting completed; the recipe then hit the pre-existing generated security-report Markdown-lint failures. - `git diff --cached --check` -- passed. --- crates/lanspread-peer/src/peer_db.rs | 83 ++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/crates/lanspread-peer/src/peer_db.rs b/crates/lanspread-peer/src/peer_db.rs index faba5a1..ad22bfc 100644 --- a/crates/lanspread-peer/src/peer_db.rs +++ b/crates/lanspread-peer/src/peer_db.rs @@ -20,6 +20,7 @@ use lanspread_proto::{ use tokio::time::Instant; pub const MAX_AUTHENTICATED_PEERS: usize = 64; +const MAX_TOTAL_REMOTE_LIBRARY_GAMES: usize = 16_384; /// Monotonic identity for one successfully authenticated endpoint observation. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -380,9 +381,23 @@ impl PeerGameDB { }) }); - let incoming_library = library - .as_ref() - .map(|snapshot| (snapshot.revision, library_map(snapshot))); + let incoming_library = library.as_ref().map(|snapshot| { + let games = library_map(snapshot); + let current_count = self + .peers + .values() + .map(|peer| peer.games.len()) + .sum::(); + let replaced_count = self + .peers + .get(&endpoint.peer_id) + .map_or(0, |peer| peer.games.len()); + let projected_count = current_count + .saturating_sub(replaced_count) + .saturating_add(games.len()); + let admitted = (projected_count <= MAX_TOTAL_REMOTE_LIBRARY_GAMES).then_some(games); + (snapshot.revision, admitted) + }); if let Some(peer) = self.peers.get_mut(&peer_id) { let previous_endpoint = (peer.addr != addr).then(|| PeerEndpoint::new(peer.peer_id, peer.addr)); @@ -401,6 +416,7 @@ impl PeerGameDB { .library_revision .is_none_or(|current| revision > current) => { + let games = games.unwrap_or_default(); let changed = peer.library_revision != Some(revision) || peer.games != games; peer.library_revision = Some(revision); peer.games = games; @@ -446,7 +462,9 @@ impl PeerGameDB { runtime_session_id, library_revision: incoming_library.as_ref().map(|(revision, _)| *revision), call_to_play_revision: None, - games: incoming_library.map_or_else(HashMap::new, |(_, games)| games), + games: incoming_library + .and_then(|(_, games)| games) + .unwrap_or_default(), }, ); self.addr_index.insert(addr, peer_id); @@ -745,6 +763,18 @@ mod tests { } } + fn large_library(revision: u64, seed: u8) -> LibrarySnapshot { + LibrarySnapshot { + revision, + games: (0..lanspread_proto::MAX_LIBRARY_GAMES) + .map(|index| GameAvailability { + game_id: format!("game-{seed}-{index:04}"), + content_id: ContentId::from_bytes([seed; 32]), + }) + .collect(), + } + } + fn commit( db: &mut PeerGameDB, endpoint: PeerEndpoint, @@ -785,6 +815,51 @@ mod tests { assert_eq!(db.peer_endpoint(&endpoint.peer_id), Some(endpoint)); } + #[test] + fn aggregate_remote_library_budget_drops_only_the_oversized_projection() { + let mut db = PeerGameDB::new(); + for seed in 1..=4 { + commit( + &mut db, + endpoint(seed, 12_000 + u16::from(seed)), + session(seed), + large_library(u64::from(seed), seed), + Some(0), + ); + } + let fifth = endpoint(5, 12_005); + let ticket = db + .begin_candidate_negotiation(fifth) + .expect("fifth candidate should reserve"); + let upsert = db + .commit_authenticated_snapshot(fifth, ticket, session(5), Some(large_library(5, 5))) + .expect("fifth snapshot should still authenticate") + .expect("fifth ticket should remain current"); + + assert!(upsert.library_changed); + assert_eq!( + db.peer_snapshots() + .iter() + .find(|peer| peer.peer_id == fifth.peer_id) + .expect("fifth peer should be retained") + .game_count, + 0 + ); + assert_eq!( + db.revision_snapshot(&fifth.peer_id) + .expect("fifth peer should have a revision watermark") + .library_revision, + Some(5), + ); + assert_eq!( + db.peer_snapshots() + .iter() + .map(|peer| peer.game_count) + .sum::(), + MAX_TOTAL_REMOTE_LIBRARY_GAMES + ); + } + #[test] fn clear_remote_peers_returns_peer_id_sorted_retirements() { let mut db = PeerGameDB::new();