diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index c5e702a..fbb0c98 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -267,6 +267,137 @@ impl PeerGameDB { seen.into_values().collect() } + /// Validates file sizes across all peers and returns only the files with majority consensus + /// Returns a tuple of (`validated_files`, `peer_whitelist`) where `peer_whitelist` contains + /// only peers that have the majority-approved file sizes + pub fn validate_file_sizes_majority( + &self, + game_id: &str, + ) -> eyre::Result<(Vec, Vec)> { + let game_files = self.game_files_for(game_id); + if game_files.is_empty() { + return Ok((Vec::new(), Vec::new())); + } + + let (file_size_map, _peer_files) = collect_file_sizes(&game_files); + let (validated_files, peer_scores) = + self.validate_each_file_consensus(game_id, file_size_map)?; + let peer_whitelist = create_peer_whitelist(peer_scores); + + Ok((validated_files, peer_whitelist)) + } + + /// Validates consensus for each file and returns validated files with peer scores + fn validate_each_file_consensus( + &self, + game_id: &str, + file_size_map: FileSizeMap, + ) -> eyre::Result<(Vec, HashMap)> { + let mut validated_files = Vec::new(); + let mut peer_whitelist_scores: HashMap = HashMap::new(); + + for (relative_path, size_map) in file_size_map { + let total_peers: usize = size_map.values().map(Vec::len).sum(); + + if total_peers == 0 { + continue; // Skip files with no size information + } + + let (consensus_size, consensus_peers) = + self.determine_size_consensus(&size_map, total_peers, &relative_path)?; + update_peer_scores(&consensus_peers, &mut peer_whitelist_scores); + + if let Some((size, peers)) = consensus_size + && let Some(file_desc) = + self.create_validated_file_description(game_id, &relative_path, size, &peers) + { + validated_files.push(file_desc); + } + } + + Ok((validated_files, peer_whitelist_scores)) + } + + /// Determines the consensus size for a file based on peer reports + /// + /// # Panics + /// + /// Panics if `size_map.iter().next()` returns None when `total_peers` == 1 + #[allow(clippy::unused_self)] + fn determine_size_consensus( + &self, + size_map: &HashMap>, + total_peers: usize, + relative_path: &str, + ) -> eyre::Result<(ConsensusResult, Vec)> { + if total_peers == 1 { + // Only one peer has this file - trust it + let (&size, peers) = size_map + .iter() + .next() + .expect("size_map should have at least one entry when total_peers == 1"); + return Ok((Some((size, peers.clone())), peers.clone())); + } + + let (majority_size, _majority_count) = find_majority_size(size_map); + + if let Some(size) = majority_size { + let majority_peers = &size_map[&size]; + let is_majority = majority_peers.len() > total_peers / 2; + + if is_majority { + // We have a clear majority + Ok((Some((size, majority_peers.clone())), majority_peers.clone())) + } else if total_peers == 2 { + // Two peers with different sizes - ambiguous, fail + eyre::bail!( + "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", + relative_path + ); + } + // If no majority and more than 2 peers, we fall back to plurality (largest group) + else { + Ok((Some((size, majority_peers.clone())), majority_peers.clone())) + } + } else { + // No clear majority and it's a tie between different sizes + if total_peers == 2 { + eyre::bail!( + "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", + relative_path + ); + } + // For more than 2 peers, we could fall back to plurality, but for now let's be strict + eyre::bail!( + "File size ambiguity for '{}': no clear majority among {} peers", + relative_path, + total_peers + ); + } + } + + /// Creates a validated file description from consensus data + fn create_validated_file_description( + &self, + game_id: &str, + relative_path: &str, + size: u64, + peers: &[SocketAddr], + ) -> Option { + if let Some(first_peer) = peers.first() + && let Some(files) = self + .peers + .get(first_peer) + .and_then(|p| p.files.get(game_id)) + && let Some(file_desc) = files + .iter() + .find(|f| f.relative_path == relative_path && f.size == Some(size)) + { + return Some(file_desc.clone()); + } + None + } + #[must_use] pub fn get_stale_peers(&self, timeout: Duration) -> Vec { self.peers @@ -277,6 +408,91 @@ impl PeerGameDB { } } +/// Type alias for file size mapping: path -> size -> peers +type FileSizeMap = HashMap>>; + +/// Type alias for peer file mapping: peer -> path -> size +type PeerFileMap = HashMap>; + +/// Type alias for consensus result: (size, peers) or None +type ConsensusResult = Option<(u64, Vec)>; + +/// Collects file sizes from all peers and organizes them by path and size +fn collect_file_sizes( + game_files: &[(SocketAddr, Vec)], +) -> (FileSizeMap, PeerFileMap) { + let mut file_size_map: FileSizeMap = HashMap::new(); + let mut peer_files: PeerFileMap = HashMap::new(); + + for (peer_addr, files) in game_files { + let mut peer_file_sizes = HashMap::new(); + for file in files { + if !file.is_dir + && let Some(size) = file.size + { + file_size_map + .entry(file.relative_path.clone()) + .or_default() + .entry(size) + .or_default() + .push(*peer_addr); + peer_file_sizes.insert(file.relative_path.clone(), size); + } + } + peer_files.insert(*peer_addr, peer_file_sizes); + } + + (file_size_map, peer_files) +} + +/// Finds the majority size from a map of sizes to peer lists +fn find_majority_size(size_map: &HashMap>) -> (Option, usize) { + let mut majority_size = None; + let mut majority_count = 0; + + for (&size, peers) in size_map { + let count = peers.len(); + if count > majority_count { + majority_count = count; + majority_size = Some(size); + } else if count == majority_count { + // Tie between different sizes - ambiguous, fail + majority_size = None; + break; + } + } + + (majority_size, majority_count) +} + +/// Updates peer scores based on consensus participation +fn update_peer_scores( + peers: &[SocketAddr], + peer_whitelist_scores: &mut HashMap, +) { + for &peer in peers { + *peer_whitelist_scores.entry(peer).or_insert(0) += 1; + } +} + +/// Creates a peer whitelist from scores, including peers with the highest scores +fn create_peer_whitelist(peer_scores: HashMap) -> Vec { + if peer_scores.is_empty() { + return Vec::new(); + } + + let max_score = *peer_scores + .values() + .max() + .expect("peer_scores should not be empty here"); + let threshold = max_score.max(1); // At least 1 file, or match the highest score + + peer_scores + .into_iter() + .filter_map(|(peer, score)| if score >= threshold { Some(peer) } else { None }) + .collect() +} + #[derive(Debug)] pub enum PeerCommand { ListGames, @@ -362,12 +578,33 @@ async fn prepare_game_storage( if let Some(parent) = validated_path.parent() { tokio::fs::create_dir_all(parent).await?; } - OpenOptions::new() + + // Create and pre-allocate the file with the expected size + let file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&validated_path) .await?; + + // Pre-allocate the file if we have size information + if let Some(size) = desc.size { + if let Err(e) = file.set_len(size).await { + log::warn!( + "Failed to pre-allocate file {} (size: {}): {}", + desc.relative_path, + size, + e + ); + // Continue without pre-allocation - the file will grow as chunks are written + } else { + log::debug!( + "Pre-allocated file {} with {} bytes", + desc.relative_path, + size + ); + } + } } } Ok(()) @@ -1097,20 +1334,52 @@ async fn handle_download_game_files_command( } let games_folder = games_folder.expect("checked above"); - let peers = { ctx.peer_game_db.read().await.peers_with_latest_version(&id) }; - if peers.is_empty() { - log::error!("No peers with latest version available to download game {id}"); - return; - } + + // Use majority validation to get trusted file descriptions and peer whitelist + let (validated_descriptions, peer_whitelist) = { + match ctx + .peer_game_db + .read() + .await + .validate_file_sizes_majority(&id) + { + Ok((files, peers)) => { + log::info!( + "Majority validation: {} validated files, {} trusted peers for game {id}", + files.len(), + peers.len() + ); + (files, peers) + } + Err(e) => { + log::error!("File size majority validation failed for {id}: {e}"); + if let Err(send_err) = + tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id: id.clone() }) + { + log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); + } + return; + } + } + }; let resolved_descriptions = if file_descriptions.is_empty() { - ctx.peer_game_db.read().await.aggregated_game_files(&id) + validated_descriptions } else { + // If user provided specific descriptions, still validate them against majority + // but keep user's selection (they might want specific files) file_descriptions }; if resolved_descriptions.is_empty() { - log::error!("No file descriptions available to download game {id}; request metadata first"); + log::error!( + "No validated file descriptions available to download game {id}; request metadata first" + ); + return; + } + + if peer_whitelist.is_empty() { + log::error!("No trusted peers available after majority validation for game {id}"); return; } @@ -1120,7 +1389,7 @@ async fn handle_download_game_files_command( &id, resolved_descriptions, games_folder, - peers, + peer_whitelist, tx_notify_ui.clone(), ) .await