diff --git a/crates/lanspread-peer/src/peer_db.rs b/crates/lanspread-peer/src/peer_db.rs index 8405d1b..89d9d41 100644 --- a/crates/lanspread-peer/src/peer_db.rs +++ b/crates/lanspread-peer/src/peer_db.rs @@ -2,7 +2,7 @@ use std::{ cmp::Reverse, - collections::HashMap, + collections::{BTreeMap, HashMap}, net::SocketAddr, time::{Duration, Instant}, }; @@ -10,7 +10,7 @@ use std::{ use lanspread_db::db::{Availability, Game, GameCatalog, GameFileDescription}; use lanspread_proto::{GameSummary, LibraryDelta, LibrarySnapshot}; -use crate::library::compute_library_digest; +use crate::{game_paths::portable_name_key, library::compute_library_digest}; pub type PeerId = String; /// Information about a discovered peer. @@ -639,7 +639,7 @@ impl PeerGameDB { .map(|(size, _)| size) } - /// Validates file sizes across all peers and returns only the files with majority consensus. + /// Validates entry shapes and file sizes across all peers. /// /// Returns a tuple of (`validated_files`, `peer_whitelist`, `file_peer_map`) where /// `peer_whitelist` contains peers that have at least one majority-approved file and @@ -653,7 +653,7 @@ impl PeerGameDB { self.validate_file_sizes_majority_from(game_id, &game_files) } - /// Validates file-size consensus over caller-sanitized complete peer manifests. + /// Validates entry consensus over caller-sanitized complete peer manifests. pub(crate) fn validate_file_sizes_majority_from( &self, game_id: &str, @@ -663,125 +663,112 @@ impl PeerGameDB { return Ok((Vec::new(), Vec::new(), HashMap::new())); } - let (file_size_map, _peer_files) = collect_file_sizes(game_files); + let entry_consensus_map = collect_manifest_entries(game_files)?; let (validated_files, peer_scores, file_peer_map) = - self.validate_each_file_consensus(game_id, file_size_map)?; + self.validate_each_entry_consensus(game_id, entry_consensus_map)?; let peer_whitelist = create_peer_whitelist(peer_scores); Ok((validated_files, peer_whitelist, file_peer_map)) } - /// Validates consensus for each file and returns validated files with peer scores. - fn validate_each_file_consensus( + /// Validates consensus for each entry and returns validated descriptions with peer scores. + fn validate_each_entry_consensus( &self, game_id: &str, - file_size_map: FileSizeMap, + entry_consensus_map: EntryConsensusMap, ) -> eyre::Result { let mut validated_files = Vec::new(); let mut peer_whitelist_scores: HashMap = HashMap::new(); let mut file_peer_map: HashMap> = HashMap::new(); - for (relative_path, size_map) in file_size_map { - let total_peers: usize = size_map.values().map(Vec::len).sum(); + for (relative_path, descriptor_map) in entry_consensus_map { + let total_peers: usize = descriptor_map.values().map(Vec::len).sum(); if total_peers == 0 { - continue; // Skip files with no size information + continue; } - let (consensus_size, consensus_peers) = - self.determine_size_consensus(&size_map, total_peers, &relative_path)?; + let (consensus_descriptor, consensus_peers) = + self.determine_entry_consensus(&descriptor_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) - { + if let Some((descriptor, peers)) = consensus_descriptor { file_peer_map.insert(relative_path.clone(), peers.clone()); - validated_files.push(file_desc); + validated_files.push(GameFileDescription { + game_id: game_id.to_owned(), + relative_path, + is_dir: descriptor.is_dir, + size: descriptor.size, + }); } } Ok((validated_files, peer_whitelist_scores, file_peer_map)) } - /// Determines the consensus size for a file based on peer reports. + /// Determines the consensus shape and size for an entry based on peer reports. /// /// # Panics /// - /// Panics if `size_map.iter().next()` returns None when `total_peers` == 1 + /// Panics if `descriptor_map.iter().next()` returns None when `total_peers` == 1 #[allow(clippy::unused_self)] - fn determine_size_consensus( + fn determine_entry_consensus( &self, - size_map: &HashMap>, + descriptor_map: &BTreeMap>, 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 + // Only one peer has this entry - trust it. + let (&descriptor, peers) = descriptor_map .iter() .next() - .expect("size_map should have at least one entry when total_peers == 1"); - return Ok((Some((size, peers.clone())), peers.clone())); + .expect("descriptor_map should have an entry when total_peers == 1"); + return Ok((Some((descriptor, peers.clone())), peers.clone())); } - let (majority_size, _majority_count) = find_majority_size(size_map); + let (majority_descriptor, _majority_count) = find_majority_descriptor(descriptor_map); - if let Some(size) = majority_size { - let majority_peers = &size_map[&size]; + if let Some(descriptor) = majority_descriptor { + let majority_peers = &descriptor_map[&descriptor]; 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())) + Ok(( + Some((descriptor, majority_peers.clone())), + majority_peers.clone(), + )) } else if total_peers == 2 { - // Two peers with different sizes - ambiguous, fail + // Two peers with different descriptions - ambiguous, fail. eyre::bail!( - "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", + "Manifest entry ambiguity for '{}': two peers report different shapes or sizes, cannot determine majority", relative_path ); } - // If no majority and more than 2 peers, we fall back to plurality (largest group) + // If no majority and more than 2 peers, fall back to the unique plurality. else { - Ok((Some((size, majority_peers.clone())), majority_peers.clone())) + Ok(( + Some((descriptor, majority_peers.clone())), + majority_peers.clone(), + )) } } else { - // No clear majority and it's a tie between different sizes + // No clear majority and the largest groups are tied. if total_peers == 2 { eyre::bail!( - "File size ambiguity for '{}': two peers report different sizes, cannot determine majority", + "Manifest entry ambiguity for '{}': two peers report different shapes or 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", + "Manifest entry ambiguity for '{}': no unique plurality 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(peer_id) = self.addr_index.get(first_peer) - && let Some(files) = self.peers.get(peer_id).and_then(|p| p.files.get(game_id)) - && let Some(file_desc) = files - .iter() - .find(|f| f.relative_path == relative_path && f.size == size) - { - return Some(file_desc.clone()); - } - None - } - /// Returns peers that haven't been seen within the timeout duration. #[must_use] pub fn get_stale_peers(&self, timeout: Duration) -> Vec { @@ -807,14 +794,27 @@ impl PeerGameDB { // Type aliases for consensus validation // ============================================================================= -/// Type alias for file size mapping: path -> size -> peers -type FileSizeMap = HashMap>>; +/// The complete description that peers vote on for one exact protocol path. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct EntryDescriptor { + is_dir: bool, + size: u64, +} -/// Type alias for peer file mapping: peer -> path -> size -type PeerFileMap = HashMap>; +impl From<&GameFileDescription> for EntryDescriptor { + fn from(description: &GameFileDescription) -> Self { + Self { + is_dir: description.is_dir, + size: description.size, + } + } +} -/// Type alias for consensus result: (size, peers) or None -type ConsensusResult = Option<(u64, Vec)>; +/// Type alias for entry consensus mapping: path -> descriptor -> peers. +type EntryConsensusMap = BTreeMap>>; + +/// Type alias for consensus result: (descriptor, peers) or None. +type ConsensusResult = Option<(EntryDescriptor, Vec)>; /// Type alias for the aggregated majority validation result. pub type MajorityValidationResult = ( @@ -834,51 +834,89 @@ type FileConsensusAggregation = ( // Helper functions for consensus validation // ============================================================================= -/// Collects file sizes from all peers and organizes them by path and size. -fn collect_file_sizes( +/// Collects exact entry descriptions from all peers. +/// +/// A portable alias cannot be merged here: the current transfer map carries one +/// protocol spelling per entry and therefore cannot translate that spelling for +/// a peer that advertised an alias-equivalent path. Reject the aggregate rather +/// than constructing a manifest that is unsafe on case-insensitive filesystems. +fn collect_manifest_entries( game_files: &[(SocketAddr, Vec)], -) -> (FileSizeMap, PeerFileMap) { - let mut file_size_map: FileSizeMap = HashMap::new(); - let mut peer_files: PeerFileMap = HashMap::new(); +) -> eyre::Result { + let mut peer_entries: BTreeMap> = BTreeMap::new(); for (peer_addr, files) in game_files { - let mut peer_file_sizes = HashMap::new(); + let entries = peer_entries.entry(*peer_addr).or_default(); for file in files { - if !file.is_dir && !peer_file_sizes.contains_key(&file.relative_path) { - let 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); + let descriptor = EntryDescriptor::from(file); + if let Some(previous) = entries.get(&file.relative_path) { + if *previous != descriptor { + eyre::bail!( + "peer {peer_addr} reported conflicting shapes or sizes for {}", + file.relative_path + ); + } + // Repeated identical entries from one peer are one vote. + continue; } + entries.insert(file.relative_path.clone(), descriptor); } - peer_files.insert(*peer_addr, peer_file_sizes); } - (file_size_map, peer_files) + let mut alias_paths = BTreeMap::::new(); + let mut entry_consensus_map: EntryConsensusMap = BTreeMap::new(); + for (peer_addr, entries) in peer_entries { + for (relative_path, descriptor) in entries { + let alias = portable_manifest_path_key(&relative_path); + if let Some(previous_path) = alias_paths.get(&alias) + && previous_path != &relative_path + { + let (first, second) = if previous_path < &relative_path { + (previous_path.as_str(), relative_path.as_str()) + } else { + (relative_path.as_str(), previous_path.as_str()) + }; + eyre::bail!("peer manifests contain platform-alias paths: {first} and {second}"); + } + alias_paths.insert(alias, relative_path.clone()); + entry_consensus_map + .entry(relative_path) + .or_default() + .entry(descriptor) + .or_default() + .push(peer_addr); + } + } + + Ok(entry_consensus_map) } -/// 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; +fn portable_manifest_path_key(relative_path: &str) -> String { + relative_path + .split('/') + .map(portable_name_key) + .collect::>() + .join("/") +} + +/// Finds the unique most popular descriptor from a map of peer votes. +fn find_majority_descriptor( + descriptor_map: &BTreeMap>, +) -> (Option, usize) { + let mut majority_descriptor = None; let mut majority_count = 0; - for (&size, peers) in size_map { + for (&descriptor, peers) in descriptor_map { let count = peers.len(); if count > majority_count { majority_count = count; - majority_size = Some(size); + majority_descriptor = Some(descriptor); } else if count == majority_count { - // Tie between different sizes - ambiguous, fail - majority_size = None; - break; + majority_descriptor = None; } } - (majority_size, majority_count) + (majority_descriptor, majority_count) } /// Updates peer scores based on consensus participation. @@ -948,6 +986,7 @@ mod tests { use std::net::SocketAddr; use super::*; + use crate::{download::ValidatedDownloadManifest, test_support::TempDir}; fn addr(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) @@ -979,6 +1018,15 @@ mod tests { } } + fn directory_desc(game_id: &str, relative_path: &str) -> GameFileDescription { + GameFileDescription { + game_id: game_id.to_string(), + relative_path: relative_path.to_string(), + is_dir: true, + size: 0, + } + } + #[test] fn aggregation_counts_only_ready_peers_as_download_sources() { let ready_addr = addr(12000); @@ -1218,4 +1266,123 @@ mod tests { Some(&vec![honest_a, honest_b]) ); } + + #[test] + fn majority_validation_preserves_empty_directory_shape() { + let peer_a = addr(12008); + let peer_b = addr(12009); + let peer_c = addr(12010); + let version = file_desc("game", "game/version.ini", 8); + let manifests = vec![ + ( + peer_c, + vec![version.clone(), file_desc("game", "game/empty-child", 0)], + ), + ( + peer_b, + vec![version.clone(), directory_desc("game", "game/empty-child")], + ), + ( + peer_a, + vec![version, directory_desc("game", "game/empty-child")], + ), + ]; + + let (validated, peer_whitelist, file_peer_map) = PeerGameDB::new() + .validate_file_sizes_majority_from("game", &manifests) + .expect("directory shape should win the majority vote"); + + assert_eq!(peer_whitelist, vec![peer_a, peer_b, peer_c]); + assert_eq!( + validated + .iter() + .map(|entry| entry.relative_path.as_str()) + .collect::>(), + vec!["game/empty-child", "game/version.ini"] + ); + let empty_child = validated + .iter() + .find(|entry| entry.relative_path == "game/empty-child") + .expect("empty directory should survive aggregation"); + assert!(empty_child.is_dir); + assert_eq!(empty_child.size, 0); + assert_eq!( + file_peer_map.get("game/empty-child"), + Some(&vec![peer_a, peer_b]) + ); + + let temp = TempDir::new("lanspread-peer-db-empty-directory"); + let manifest = ValidatedDownloadManifest::from_protocol_v7( + temp.path(), + "game", + validated, + &GameCatalog::from_ids(["game".to_owned()]), + ) + .expect("aggregated directory should enter the validated storage manifest"); + let storage_entries = manifest.transfer_entries().collect::>(); + assert_eq!(storage_entries.len(), 1); + let storage_entry = storage_entries[0]; + assert!(storage_entry.is_dir()); + assert_eq!(storage_entry.size(), 0); + } + + #[test] + fn two_peer_file_directory_shape_conflict_is_deterministic() { + let peer_a = addr(12011); + let peer_b = addr(12012); + let version = file_desc("game", "game/version.ini", 8); + let first_order = vec![ + ( + peer_a, + vec![version.clone(), directory_desc("game", "game/cache")], + ), + ( + peer_b, + vec![version.clone(), file_desc("game", "game/cache", 0)], + ), + ]; + let mut reverse_order = first_order.clone(); + reverse_order.reverse(); + + let first_error = PeerGameDB::new() + .validate_file_sizes_majority_from("game", &first_order) + .expect_err("a tied file/directory shape must be rejected") + .to_string(); + let reverse_error = PeerGameDB::new() + .validate_file_sizes_majority_from("game", &reverse_order) + .expect_err("input ordering must not resolve a tied shape") + .to_string(); + + assert_eq!(first_error, reverse_error); + assert!(first_error.contains("different shapes or sizes")); + assert!(first_error.contains("game/cache")); + } + + #[test] + fn cross_peer_platform_aliases_fail_deterministically() { + let peer_a = addr(12013); + let peer_b = addr(12014); + let version = file_desc("game", "game/version.ini", 8); + let first_order = vec![ + ( + peer_a, + vec![version.clone(), directory_desc("game", "game/Data")], + ), + (peer_b, vec![version, directory_desc("game", "game/data")]), + ]; + let mut reverse_order = first_order.clone(); + reverse_order.reverse(); + + let first_error = PeerGameDB::new() + .validate_file_sizes_majority_from("game", &first_order) + .expect_err("portable path aliases cannot share one transfer spelling") + .to_string(); + let reverse_error = PeerGameDB::new() + .validate_file_sizes_majority_from("game", &reverse_order) + .expect_err("alias rejection must not depend on peer ordering") + .to_string(); + + assert_eq!(first_error, reverse_error); + assert!(first_error.contains("game/Data and game/data")); + } }