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.
This commit is contained in:
2026-09-12 12:23:42 +02:00
parent c892838d72
commit 0189622085
+79 -4
View File
@@ -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::<usize>();
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::<usize>(),
MAX_TOTAL_REMOTE_LIBRARY_GAMES
);
}
#[test]
fn clear_remote_peers_returns_peer_id_sorted_retirements() {
let mut db = PeerGameDB::new();