diff --git a/crates/lanspread-peer-cli/src/main.rs b/crates/lanspread-peer-cli/src/main.rs index 05a9e76..aa8582c 100644 --- a/crates/lanspread-peer-cli/src/main.rs +++ b/crates/lanspread-peer-cli/src/main.rs @@ -276,10 +276,9 @@ async fn handle_command( } => { ensure_catalog_game(shared, game_id).await?; ensure_no_active_operation(shared, game_id).await?; - let files = game_files_for_download(sender, shared, game_id).await?; + game_files_for_download(sender, shared, game_id).await?; sender.send(PeerCommand::DownloadGameFilesWithOptions { id: game_id.clone(), - file_descriptions: files, install_after_download: *install_after_download, })?; Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download})) diff --git a/crates/lanspread-peer/src/download/manifest.rs b/crates/lanspread-peer/src/download/manifest.rs new file mode 100644 index 0000000..fecc0dc --- /dev/null +++ b/crates/lanspread-peer/src/download/manifest.rs @@ -0,0 +1,1011 @@ +use std::{ + collections::BTreeMap, + fs::Metadata, + path::{Path, PathBuf}, +}; + +use eyre::WrapErr; +use lanspread_db::db::{GameCatalog, GameFileDescription}; + +/// A remote manifest may describe at most this many filesystem entries. +pub(crate) const MAX_DOWNLOAD_MANIFEST_ENTRIES: usize = 100_000; +/// A single remotely described file may be at most one tebibyte. +pub(crate) const MAX_DOWNLOAD_FILE_BYTES: u64 = 1024 * 1024 * 1024 * 1024; +/// A complete remotely described game may be at most sixteen tebibytes. +pub(crate) const MAX_DOWNLOAD_MANIFEST_BYTES: u64 = 16 * MAX_DOWNLOAD_FILE_BYTES; +/// The root sentinel is parsed in memory and should contain only a version value. +pub(crate) const MAX_VERSION_INI_BYTES: u64 = 64 * 1024; +/// Portable filesystems support at least 255 bytes per ordinary component. +pub(crate) const MAX_DOWNLOAD_COMPONENT_BYTES: usize = 255; +/// Leave room for the configured game root under conservative 1,024-unit paths. +pub(crate) const MAX_DOWNLOAD_RELATIVE_PATH_BYTES: usize = 900; +const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000; + +const VERSION_INI: &str = "version.ini"; + +/// One entry whose path and shape were validated as part of a complete manifest. +#[derive(Clone, Debug)] +pub(crate) struct ValidatedDownloadEntry { + canonical_path: String, + protocol_path: String, + is_dir: bool, + size: u64, +} + +impl ValidatedDownloadEntry { + pub(crate) fn canonical_path(&self) -> &str { + &self.canonical_path + } + + #[cfg(test)] + fn protocol_path(&self) -> &str { + &self.protocol_path + } + + pub(crate) const fn is_dir(&self) -> bool { + self.is_dir + } + + pub(crate) const fn size(&self) -> u64 { + self.size + } + + pub(crate) fn is_version_ini(&self) -> bool { + self.canonical_path == VERSION_INI + } + + pub(crate) fn protocol_description(&self, game_id: &str) -> GameFileDescription { + GameFileDescription { + game_id: game_id.to_owned(), + relative_path: self.protocol_path.clone(), + is_dir: self.is_dir, + size: self.size, + } + } +} + +/// A complete download description validated before any filesystem mutation. +#[derive(Clone, Debug)] +pub(crate) struct ValidatedDownloadManifest { + game_id: String, + games_folder: PathBuf, + game_root: PathBuf, + entries: Vec, +} + +impl ValidatedDownloadManifest { + /// Contains current protocol-7 descriptions within one known catalog game root. + pub(crate) fn from_protocol_v7( + games_folder: &Path, + game_id: &str, + descriptions: Vec, + catalog: &GameCatalog, + ) -> eyre::Result { + if !catalog.contains(game_id) { + eyre::bail!("cannot download unknown catalog game {game_id}"); + } + if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES { + eyre::bail!( + "download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}", + descriptions.len() + ); + } + + validate_game_id(game_id)?; + let games_folder = canonical_games_folder(games_folder)?; + let game_root = games_folder.join(game_id); + validate_game_root(&game_root)?; + let mut builder = + ProtocolV7ManifestBuilder::new(game_id, Some(&game_root), descriptions.len())?; + for description in descriptions { + builder.push(description)?; + } + let entries = builder.finish()?; + + Ok(Self { + game_id: game_id.to_owned(), + games_folder, + game_root, + entries, + }) + } + + pub(crate) fn game_id(&self) -> &str { + &self.game_id + } + + pub(crate) fn games_folder(&self) -> &Path { + &self.games_folder + } + + pub(crate) fn game_root(&self) -> &Path { + &self.game_root + } + + #[cfg(test)] + fn entries(&self) -> &[ValidatedDownloadEntry] { + &self.entries + } + + pub(crate) fn transfer_entries(&self) -> impl Iterator { + self.entries.iter().filter(|entry| !entry.is_version_ini()) + } + + pub(crate) fn protocol_descriptions(&self) -> Vec { + self.entries + .iter() + .map(|entry| entry.protocol_description(&self.game_id)) + .collect() + } +} + +/// Validates one peer's complete current-wire description before aggregation. +pub(crate) fn validate_protocol_v7_descriptions( + game_id: &str, + descriptions: Vec, +) -> eyre::Result> { + if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES { + eyre::bail!( + "download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}", + descriptions.len() + ); + } + validate_game_id(game_id)?; + let mut builder = ProtocolV7ManifestBuilder::new(game_id, None, descriptions.len())?; + for description in descriptions { + builder.push(description)?; + } + Ok(builder + .finish()? + .into_iter() + .map(|entry| entry.protocol_description(game_id)) + .collect()) +} + +struct ProtocolV7ManifestBuilder<'a> { + game_id: &'a str, + game_root: Option<&'a Path>, + prefix: String, + game_alias: String, + entries: Vec, + shapes: BTreeMap, + total_bytes: u64, + saw_protocol_root: bool, +} + +impl<'a> ProtocolV7ManifestBuilder<'a> { + fn new(game_id: &'a str, game_root: Option<&'a Path>, capacity: usize) -> eyre::Result { + Ok(Self { + game_id, + game_root, + prefix: format!("{game_id}/"), + game_alias: windows_alias_component(game_id)?, + entries: Vec::with_capacity(capacity), + shapes: BTreeMap::new(), + total_bytes: 0, + saw_protocol_root: false, + }) + } + + fn push(&mut self, description: GameFileDescription) -> eyre::Result<()> { + validate_protocol_game_id(self.game_id, &description)?; + if self.take_redundant_root(&description)? { + return Ok(()); + } + if description.relative_path.contains('\\') { + eyre::bail!( + "download path must use forward slashes: {}", + description.relative_path + ); + } + + let canonical_path = description + .relative_path + .strip_prefix(&self.prefix) + .ok_or_else(|| { + eyre::eyre!( + "download path must start with exactly {}: {}", + self.prefix, + description.relative_path + ) + })?; + let (alias_path, components) = validate_canonical_path(canonical_path)?; + self.validate_root_component(&components, &description.relative_path)?; + validate_entry_shape( + &mut self.shapes, + &alias_path, + description.is_dir, + &description.relative_path, + )?; + self.account_size(canonical_path, &description)?; + if let Some(game_root) = self.game_root { + validate_existing_destination( + game_root, + &components, + description.is_dir, + &description.relative_path, + )?; + } + self.entries.push(ValidatedDownloadEntry { + canonical_path: canonical_path.to_owned(), + protocol_path: description.relative_path, + is_dir: description.is_dir, + size: description.size, + }); + Ok(()) + } + + fn take_redundant_root(&mut self, description: &GameFileDescription) -> eyre::Result { + if description.relative_path != self.game_id { + return Ok(false); + } + if description.is_dir && description.size == 0 { + if self.saw_protocol_root { + eyre::bail!("duplicate protocol game-root entry for {}", self.game_id); + } + self.saw_protocol_root = true; + return Ok(true); + } + eyre::bail!( + "the protocol game-root entry for {} must be a zero-sized directory", + self.game_id + ) + } + + fn validate_root_component(&self, components: &[&str], display_path: &str) -> eyre::Result<()> { + let root_component = components + .first() + .expect("validated canonical paths have one component"); + if windows_alias_component(root_component)? == self.game_alias { + eyre::bail!("download path contains a doubled game prefix: {display_path}"); + } + if is_protected_root_component(root_component) { + eyre::bail!("download path targets install or recovery state: {display_path}"); + } + Ok(()) + } + + fn account_size( + &mut self, + canonical_path: &str, + description: &GameFileDescription, + ) -> eyre::Result<()> { + if description.is_dir { + if description.size != 0 { + eyre::bail!( + "directory entry has a non-zero size: {}", + description.relative_path + ); + } + return Ok(()); + } + if description.size > MAX_DOWNLOAD_FILE_BYTES { + eyre::bail!( + "download file exceeds the {MAX_DOWNLOAD_FILE_BYTES}-byte limit: {}", + description.relative_path + ); + } + if canonical_path == VERSION_INI && description.size > MAX_VERSION_INI_BYTES { + eyre::bail!( + "root version.ini exceeds the {MAX_VERSION_INI_BYTES}-byte limit: {}", + description.relative_path + ); + } + self.total_bytes = self + .total_bytes + .checked_add(description.size) + .ok_or_else(|| eyre::eyre!("download manifest byte count overflow"))?; + if self.total_bytes > MAX_DOWNLOAD_MANIFEST_BYTES { + eyre::bail!("download manifest exceeds the {MAX_DOWNLOAD_MANIFEST_BYTES}-byte limit"); + } + Ok(()) + } + + fn finish(mut self) -> eyre::Result> { + self.entries + .sort_by(|left, right| left.canonical_path.cmp(&right.canonical_path)); + let versions = self + .entries + .iter() + .filter(|entry| entry.is_version_ini()) + .collect::>(); + let [version_ini] = versions.as_slice() else { + eyre::bail!( + "expected exactly one regular root version.ini for {}, found {}", + self.game_id, + versions.len() + ); + }; + if version_ini.is_dir { + eyre::bail!( + "root version.ini for {} must be a regular file", + self.game_id + ); + } + Ok(self.entries) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EntryShape { + File, + Directory, +} + +fn validate_game_id(game_id: &str) -> eyre::Result<()> { + if game_id.contains('/') || game_id.contains('\\') { + eyre::bail!("catalog game ID must be one path component: {game_id}"); + } + validate_component(game_id)?; + if is_protected_root_component(game_id) { + eyre::bail!("catalog game ID is reserved for application state: {game_id}"); + } + Ok(()) +} + +fn validate_protocol_game_id( + requested_game_id: &str, + description: &GameFileDescription, +) -> eyre::Result<()> { + if description.game_id != requested_game_id { + eyre::bail!( + "description for {} cannot be used to download {requested_game_id}", + description.game_id + ); + } + Ok(()) +} + +fn canonical_games_folder(games_folder: &Path) -> eyre::Result { + if !games_folder.is_absolute() { + eyre::bail!( + "configured games directory must be absolute: {}", + games_folder.display() + ); + } + let canonical = std::fs::canonicalize(games_folder).wrap_err_with(|| { + format!( + "failed to resolve configured games directory {}", + games_folder.display() + ) + })?; + let metadata = std::fs::metadata(&canonical)?; + if !metadata.is_dir() { + eyre::bail!( + "configured games directory is not a directory: {}", + canonical.display() + ); + } + Ok(canonical) +} + +fn validate_game_root(game_root: &Path) -> eyre::Result<()> { + let Some(metadata) = symlink_metadata_if_exists(game_root)? else { + return Ok(()); + }; + if is_link_or_reparse(&metadata) { + eyre::bail!( + "game root must not be a symlink or reparse point: {}", + game_root.display() + ); + } + if !metadata.is_dir() { + eyre::bail!("game root is not a directory: {}", game_root.display()); + } + Ok(()) +} + +fn validate_canonical_path(path: &str) -> eyre::Result<(String, Vec<&str>)> { + if path.is_empty() { + eyre::bail!("download path cannot be empty"); + } + if path.starts_with('/') || path.ends_with('/') { + eyre::bail!("download path is not canonical: {path}"); + } + if path.contains('\0') { + eyre::bail!("download path contains a NUL byte"); + } + if path.len() > MAX_DOWNLOAD_RELATIVE_PATH_BYTES { + eyre::bail!("download path exceeds the {MAX_DOWNLOAD_RELATIVE_PATH_BYTES}-byte limit"); + } + + let components = path.split('/').collect::>(); + let mut aliases = Vec::with_capacity(components.len()); + for component in &components { + validate_component(component)?; + aliases.push(windows_alias_component(component)?); + } + Ok((aliases.join("/"), components)) +} + +fn validate_component(component: &str) -> eyre::Result<()> { + if component.is_empty() || matches!(component, "." | "..") { + eyre::bail!("download path contains a non-canonical component: {component:?}"); + } + if component.ends_with([' ', '.']) { + eyre::bail!("download path component has a trailing dot or space: {component}"); + } + if component.len() > MAX_DOWNLOAD_COMPONENT_BYTES { + eyre::bail!( + "download path component exceeds the {MAX_DOWNLOAD_COMPONENT_BYTES}-byte limit" + ); + } + if component.chars().any(|character| { + character <= '\u{1f}' || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*') + }) { + eyre::bail!("download path component is not portable: {component}"); + } + let device_stem = component.split('.').next().unwrap_or_default(); + if is_windows_device_name(device_stem) { + eyre::bail!("download path uses a Windows device name: {component}"); + } + if looks_like_dos_short_name(component) { + eyre::bail!("download path resembles a Windows short-name alias: {component}"); + } + Ok(()) +} + +fn windows_alias_component(component: &str) -> eyre::Result { + validate_component(component)?; + Ok(component.to_uppercase()) +} + +fn is_windows_device_name(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || upper + .strip_prefix("COM") + .or_else(|| upper.strip_prefix("LPT")) + .is_some_and(|number| { + (number.len() == 1 && matches!(number.as_bytes()[0], b'1'..=b'9')) + || matches!(number, "¹" | "²" | "³") + }) +} + +fn looks_like_dos_short_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or_default(); + stem.rsplit_once('~').is_some_and(|(prefix, suffix)| { + !prefix.is_empty() + && !suffix.is_empty() + && suffix.len() <= 6 + && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn is_protected_root_component(component: &str) -> bool { + let alias = component.to_uppercase(); + alias == "LOCAL" + || alias.starts_with(".LOCAL.") + || alias.starts_with(".VERSION.INI.") + || matches!( + alias.as_str(), + ".SYNC" + | ".LANSPREAD" + | ".LANSPREAD.JSON" + | ".LANSPREAD.JSON.TMP" + | ".LANSPREAD_OWNED" + | ".SOFTLAN_FIRST_START_DONE" + | ".SOFTLAN_GAME_INSTALLED" + | "INSTALL_INTENT.JSON" + | "INSTALL_INTENT.JSON.TMP" + ) +} + +fn validate_entry_shape( + shapes: &mut BTreeMap, + alias_path: &str, + is_dir: bool, + display_path: &str, +) -> eyre::Result<()> { + let shape = if is_dir { + EntryShape::Directory + } else { + EntryShape::File + }; + if shapes.insert(alias_path.to_owned(), shape).is_some() { + eyre::bail!("duplicate or platform-alias download path: {display_path}"); + } + + let mut parent = alias_path; + while let Some((prefix, _)) = parent.rsplit_once('/') { + if shapes.get(prefix) == Some(&EntryShape::File) { + eyre::bail!("download path descends through a file: {display_path}"); + } + parent = prefix; + } + + if shape == EntryShape::File { + let descendant_prefix = format!("{alias_path}/"); + if shapes + .range(descendant_prefix.clone()..) + .next() + .is_some_and(|(candidate, _)| candidate.starts_with(&descendant_prefix)) + { + eyre::bail!("download file conflicts with a described child: {display_path}"); + } + } + Ok(()) +} + +fn validate_existing_destination( + game_root: &Path, + components: &[&str], + is_dir: bool, + display_path: &str, +) -> eyre::Result<()> { + let mut destination = game_root.to_path_buf(); + for component in components { + destination.push(component); + } + if platform_path_units(&destination) > MAX_DOWNLOAD_DESTINATION_UNITS { + eyre::bail!( + "download destination exceeds the {MAX_DOWNLOAD_DESTINATION_UNITS}-unit limit: {display_path}" + ); + } + + destination = game_root.to_path_buf(); + for (index, component) in components.iter().enumerate() { + destination.push(component); + let Some(metadata) = symlink_metadata_if_exists(&destination)? else { + continue; + }; + if is_link_or_reparse(&metadata) { + eyre::bail!("download destination contains a symlink or reparse point: {display_path}"); + } + let is_final = index + 1 == components.len(); + if !is_final && !metadata.is_dir() { + eyre::bail!("download destination descends through a file: {display_path}"); + } + if is_final && ((is_dir && !metadata.is_dir()) || (!is_dir && !metadata.is_file())) { + eyre::bail!("download destination has the wrong filesystem type: {display_path}"); + } + } + Ok(()) +} + +#[cfg(unix)] +fn platform_path_units(path: &Path) -> usize { + use std::os::unix::ffi::OsStrExt; + + path.as_os_str().as_bytes().len() +} + +#[cfg(windows)] +fn platform_path_units(path: &Path) -> usize { + use std::os::windows::ffi::OsStrExt; + + path.as_os_str().encode_wide().count() +} + +#[cfg(not(any(unix, windows)))] +fn platform_path_units(path: &Path) -> usize { + path.as_os_str().to_string_lossy().len() +} + +fn symlink_metadata_if_exists(path: &Path) -> eyre::Result> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn is_link_or_reparse(metadata: &Metadata) -> bool { + metadata.file_type().is_symlink() || is_windows_reparse_point(metadata) +} + +#[cfg(windows)] +fn is_windows_reparse_point(metadata: &Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +const fn is_windows_reparse_point(_metadata: &Metadata) -> bool { + false +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::test_support::TempDir; + + #[derive(Debug, PartialEq, Eq)] + enum TreeEntry { + Directory, + File(Vec), + Symlink(PathBuf), + Other, + } + + fn catalog() -> GameCatalog { + GameCatalog::from_ids(["game".to_owned()]) + } + + fn file(path: &str, size: u64) -> GameFileDescription { + GameFileDescription { + game_id: "game".to_owned(), + relative_path: path.to_owned(), + is_dir: false, + size, + } + } + + fn directory(path: &str) -> GameFileDescription { + GameFileDescription { + game_id: "game".to_owned(), + relative_path: path.to_owned(), + is_dir: true, + size: 0, + } + } + + fn valid_descriptions() -> Vec { + vec![ + directory("game"), + file("game/archive.eti", 10), + file("game/version.ini", 8), + ] + } + + fn validate( + temp: &TempDir, + descriptions: Vec, + ) -> eyre::Result { + ValidatedDownloadManifest::from_protocol_v7(temp.path(), "game", descriptions, &catalog()) + } + + fn snapshot_tree(root: &Path) -> BTreeMap { + walkdir::WalkDir::new(root) + .follow_links(false) + .into_iter() + .map(|entry| entry.expect("test tree should be readable")) + .filter(|entry| entry.path() != root) + .map(|entry| { + let relative = entry + .path() + .strip_prefix(root) + .expect("entry should be below root") + .to_path_buf(); + let file_type = entry.file_type(); + let value = if file_type.is_dir() { + TreeEntry::Directory + } else if file_type.is_file() { + TreeEntry::File( + std::fs::read(entry.path()).expect("test file should be readable"), + ) + } else if file_type.is_symlink() { + TreeEntry::Symlink( + std::fs::read_link(entry.path()).expect("test link should be readable"), + ) + } else { + TreeEntry::Other + }; + (relative, value) + }) + .collect() + } + + fn write_file(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("parent should be created"); + } + std::fs::write(path, bytes).expect("test file should be written"); + } + + fn assert_rejected_without_mutation(descriptions: Vec) { + let temp = TempDir::new("lanspread-manifest-unchanged"); + write_file(&temp.game_root().join("archive.eti"), b"original"); + write_file(&temp.game_root().join("version.ini"), b"20250101"); + write_file(&temp.game_root().join("local/save.dat"), b"save"); + write_file(&temp.path().join("sibling/local/save.dat"), b"sibling"); + let before = snapshot_tree(temp.path()); + + assert!(validate(&temp, descriptions).is_err()); + assert_eq!(snapshot_tree(temp.path()), before); + } + + #[test] + fn protocol_v7_adapter_strips_exact_game_prefix() { + let temp = TempDir::new("lanspread-manifest-valid"); + let manifest = validate(&temp, valid_descriptions()).expect("manifest should validate"); + + let paths = manifest + .entries() + .iter() + .map(ValidatedDownloadEntry::canonical_path) + .collect::>(); + assert_eq!(paths, ["archive.eti", "version.ini"]); + let version_ini = manifest + .entries() + .iter() + .find(|entry| entry.is_version_ini()) + .expect("version.ini should exist"); + assert_eq!(version_ini.protocol_path(), "game/version.ini"); + } + + #[test] + fn rejects_unknown_catalog_game() { + let temp = TempDir::new("lanspread-manifest-unknown"); + let error = ValidatedDownloadManifest::from_protocol_v7( + temp.path(), + "unknown", + Vec::new(), + &catalog(), + ) + .expect_err("unknown game should fail"); + assert!(error.to_string().contains("unknown catalog game")); + } + + #[test] + fn rejects_missing_different_and_doubled_game_prefixes() { + let temp = TempDir::new("lanspread-manifest-prefix"); + for path in ["version.ini", "other/version.ini", "game/game/version.ini"] { + let descriptions = vec![file("game/archive.eti", 10), file(path, 8)]; + assert!(validate(&temp, descriptions).is_err(), "accepted {path}"); + } + } + + #[test] + fn rejects_cross_game_description_identity() { + let temp = TempDir::new("lanspread-manifest-cross-game"); + let mut descriptions = valid_descriptions(); + descriptions[1].game_id = "other".to_owned(); + assert!(validate(&temp, descriptions).is_err()); + } + + #[test] + fn rejects_noncanonical_and_nonportable_paths() { + let temp = TempDir::new("lanspread-manifest-noncanonical"); + for path in [ + "game/a\\b.eti", + "game//archive.eti", + "game/./archive.eti", + "game/../archive.eti", + "game/archive.eti/", + "game/C:/archive.eti", + "game/archive?.eti", + "game/archive.eti\0suffix", + ] { + let descriptions = vec![file(path, 10), file("game/version.ini", 8)]; + assert!(validate(&temp, descriptions).is_err(), "accepted {path:?}"); + } + } + + #[test] + fn rejects_portability_aliases_and_path_length_overflows() { + let temp = TempDir::new("lanspread-manifest-portability"); + for path in [ + "game/.ſync/state", + "game/COM¹.txt", + "game/LPT³.log", + "game/LOCAL~1/save.dat", + ] { + let descriptions = vec![file(path, 1), file("game/version.ini", 8)]; + assert!(validate(&temp, descriptions).is_err(), "accepted {path}"); + } + + let long_component = format!("game/{}", "a".repeat(MAX_DOWNLOAD_COMPONENT_BYTES + 1)); + assert!( + validate( + &temp, + vec![file(&long_component, 1), file("game/version.ini", 8)] + ) + .is_err() + ); + + let long_relative = std::iter::repeat_n("a".repeat(200), 5) + .collect::>() + .join("/"); + let long_path = format!("game/{long_relative}"); + assert!( + validate( + &temp, + vec![file(&long_path, 1), file("game/version.ini", 8)] + ) + .is_err() + ); + } + + #[test] + fn rejects_install_and_recovery_owned_roots() { + let temp = TempDir::new("lanspread-manifest-reserved"); + for component in [ + "local", + "LOCAL", + ".local.installing", + ".local.backup", + ".sync", + ".lanspread", + ".lanspread.json", + ".lanspread.json.tmp", + ".lanspread_owned", + ".softlan_first_start_done", + ".softlan_game_installed", + ".version.ini.tmp", + ".version.ini.discarded", + "install_intent.json", + "install_intent.json.tmp", + ] { + let path = format!("game/{component}/payload.bin"); + let descriptions = vec![file(&path, 10), file("game/version.ini", 8)]; + assert!( + validate(&temp, descriptions).is_err(), + "accepted protected path {path}" + ); + } + } + + #[test] + fn rejects_duplicates_platform_aliases_and_shape_conflicts() { + let temp = TempDir::new("lanspread-manifest-alias"); + let cases = [ + vec![file("game/A.eti", 1), file("game/a.eti", 1)], + vec![file("game/archive.eti", 1), file("game/archive.eti", 1)], + vec![file("game/con.txt", 1)], + vec![file("game/archive.eti.", 1)], + vec![file("game/archive.eti ", 1)], + vec![file("game/dir", 1), file("game/dir/child", 1)], + vec![file("game/dir/child", 1), file("game/dir", 1)], + vec![directory("game/dir"), file("game/dir", 1)], + vec![directory("game"), directory("game")], + ]; + for mut descriptions in cases { + descriptions.push(file("game/version.ini", 8)); + assert!(validate(&temp, descriptions).is_err()); + } + } + + #[test] + fn raw_peer_validation_rejects_duplicates_before_consensus() { + let descriptions = vec![ + file("game/version.ini", 8), + file("game/archive.eti", 1), + file("game/archive.eti", 1), + ]; + assert!(validate_protocol_v7_descriptions("game", descriptions).is_err()); + } + + #[test] + fn requires_exactly_one_regular_root_version_ini() { + let temp = TempDir::new("lanspread-manifest-version"); + assert!(validate(&temp, vec![file("game/archive.eti", 1)]).is_err()); + assert!( + validate( + &temp, + vec![file("game/version.ini", 8), file("game/version.ini", 8)] + ) + .is_err() + ); + assert!(validate(&temp, vec![directory("game/version.ini")]).is_err()); + } + + #[test] + fn rejects_nonzero_directory_and_size_limits() { + let temp = TempDir::new("lanspread-manifest-limits"); + let mut bad_dir = directory("game/data"); + bad_dir.size = 1; + assert!(validate(&temp, vec![bad_dir, file("game/version.ini", 8)]).is_err()); + assert!( + validate( + &temp, + vec![ + file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1), + file("game/version.ini", 8), + ] + ) + .is_err() + ); + assert!( + validate( + &temp, + vec![ + file("game/version.ini", MAX_VERSION_INI_BYTES + 1), + file("game/archive.eti", 1), + ] + ) + .is_err() + ); + + let descriptions = vec![file("game/version.ini", 8); MAX_DOWNLOAD_MANIFEST_ENTRIES + 1]; + assert!(validate(&temp, descriptions).is_err()); + } + + #[test] + fn hostile_late_descriptor_leaves_filesystem_unchanged() { + let temp = TempDir::new("lanspread-manifest-zero-mutation"); + let game_root = temp.game_root(); + std::fs::create_dir_all(&game_root).expect("game root should be created"); + std::fs::write(game_root.join("archive.eti"), b"original") + .expect("existing archive should be written"); + std::fs::write(game_root.join("version.ini"), b"20250101") + .expect("existing version should be written"); + + let descriptions = vec![ + file("game/archive.eti", 1), + file("game/version.ini", 8), + file("game/local/save.dat", 1), + ]; + assert!(validate(&temp, descriptions).is_err()); + + assert_eq!( + std::fs::read(game_root.join("archive.eti")).expect("archive should remain"), + b"original" + ); + assert_eq!( + std::fs::read(game_root.join("version.ini")).expect("version should remain"), + b"20250101" + ); + assert_eq!( + std::fs::read_dir(&game_root) + .expect("game root should remain readable") + .count(), + 2 + ); + } + + #[test] + fn rejection_categories_leave_the_complete_tree_unchanged() { + let cases = [ + vec![file("game/version.ini", 8), file("other/local/save.dat", 1)], + vec![file("game/version.ini", 8), file("game/local/save.dat", 1)], + vec![file("game/version.ini", 8), file("game/.sync/state", 1)], + vec![file("game/version.ini", 8), file("game/../escape", 1)], + vec![ + file("game/version.ini", 8), + file("game/A.eti", 1), + file("game/a.eti", 1), + ], + vec![ + file("game/version.ini", 8), + file("game/dir", 1), + file("game/dir/child", 1), + ], + vec![file("game/archive.eti", 1)], + vec![directory("game/version.ini")], + vec![ + file("game/version.ini", 8), + file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1), + ], + vec![ + directory("game"), + directory("game"), + file("game/version.ini", 8), + ], + ]; + for descriptions in cases { + assert_rejected_without_mutation(descriptions); + } + + assert_rejected_without_mutation(vec![ + file("game/version.ini", 8); + MAX_DOWNLOAD_MANIFEST_ENTRIES + 1 + ]); + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_game_roots_and_destination_components() { + use std::os::unix::fs::symlink; + + let root_link = TempDir::new("lanspread-manifest-root-link"); + let outside = TempDir::new("lanspread-manifest-outside"); + symlink(outside.path(), root_link.path().join("game")) + .expect("game root symlink should be created"); + assert!(validate(&root_link, valid_descriptions()).is_err()); + + let child_link = TempDir::new("lanspread-manifest-child-link"); + std::fs::create_dir_all(child_link.game_root()).expect("game root should be created"); + symlink(outside.path(), child_link.game_root().join("payload")) + .expect("child symlink should be created"); + let descriptions = vec![ + file("game/payload/file.bin", 1), + file("game/version.ini", 8), + ]; + assert!(validate(&child_link, descriptions).is_err()); + } +} diff --git a/crates/lanspread-peer/src/download/mod.rs b/crates/lanspread-peer/src/download/mod.rs index a91bb03..5b6927c 100644 --- a/crates/lanspread-peer/src/download/mod.rs +++ b/crates/lanspread-peer/src/download/mod.rs @@ -1,5 +1,6 @@ //! Download pipeline for game files from peers. +mod manifest; mod orchestrator; mod planning; mod progress; @@ -8,4 +9,5 @@ mod storage; mod transport; mod version_ini; -pub use orchestrator::download_game_files; +pub(crate) use manifest::{ValidatedDownloadManifest, validate_protocol_v7_descriptions}; +pub(crate) use orchestrator::download_game_files; diff --git a/crates/lanspread-peer/src/download/orchestrator.rs b/crates/lanspread-peer/src/download/orchestrator.rs index 64b3666..6417372 100644 --- a/crates/lanspread-peer/src/download/orchestrator.rs +++ b/crates/lanspread-peer/src/download/orchestrator.rs @@ -1,15 +1,11 @@ -use std::{ - collections::HashMap, - net::SocketAddr, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc}; use lanspread_db::db::GameFileDescription; use tokio::sync::mpsc::UnboundedSender; use tokio_util::sync::CancellationToken; use super::{ + manifest::ValidatedDownloadManifest, planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor}, progress::{DownloadProgressTracker, sample_download_progress}, retry::{RetryContext, retry_failed_chunks}, @@ -26,15 +22,15 @@ use crate::{PeerEvent, config::MAX_RETRY_COUNT}; /// Downloads all game files from available peers. #[allow(clippy::too_many_lines)] -pub async fn download_game_files( - game_id: &str, - game_file_descs: Vec, - games_folder: PathBuf, +pub(crate) async fn download_game_files( + manifest: ValidatedDownloadManifest, peers: Vec, file_peer_map: HashMap>, tx_notify_ui: UnboundedSender, cancel_token: CancellationToken, ) -> eyre::Result<()> { + let game_id = manifest.game_id().to_owned(); + let games_folder = manifest.games_folder().to_path_buf(); if peers.is_empty() { eyre::bail!("no peers available for game {game_id}"); } @@ -43,40 +39,41 @@ pub async fn download_game_files( eyre::bail!("download cancelled for game {game_id}"); } - let (version_desc, transfer_descs) = extract_version_descriptor(game_id, game_file_descs)?; + let game_file_descs = manifest.protocol_descriptions(); + let (version_desc, transfer_descs) = extract_version_descriptor(&game_id, game_file_descs)?; let version_buffer = match VersionIniBuffer::new(&version_desc) { Ok(buffer) => Arc::new(buffer), Err(err) => return Err(err), }; - let game_root = games_folder.join(game_id); + let game_root = manifest.game_root().to_path_buf(); begin_version_ini_transaction(&game_root).await?; if cancel_token.is_cancelled() { rollback_version_ini_transaction(&game_root).await; - discard_cancelled_download_best_effort(&games_folder, game_id).await; + discard_cancelled_download_best_effort(&games_folder, &game_id).await; eyre::bail!("download cancelled for game {game_id}"); } - if let Err(err) = prepare_game_storage(&games_folder, &transfer_descs).await { + if let Err(err) = prepare_game_storage(&manifest).await { rollback_version_ini_transaction(&game_root).await; if cancel_token.is_cancelled() { - discard_cancelled_download_best_effort(&games_folder, game_id).await; + discard_cancelled_download_best_effort(&games_folder, &game_id).await; eyre::bail!("download cancelled for game {game_id}"); } return Err(err); } if cancel_token.is_cancelled() { rollback_version_ini_transaction(&game_root).await; - discard_cancelled_download_best_effort(&games_folder, game_id).await; + discard_cancelled_download_best_effort(&games_folder, &game_id).await; eyre::bail!("download cancelled for game {game_id}"); } tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin { - id: game_id.to_string(), + id: game_id.clone(), })?; let progress_tracker = DownloadProgressTracker::new(total_download_bytes(&transfer_descs)); let transfer_ctx = TransferContext { - game_id, + game_id: &game_id, games_folder: &games_folder, peers: &peers, file_peer_map: &file_peer_map, @@ -86,7 +83,7 @@ pub async fn download_game_files( progress_tracker: progress_tracker.clone(), }; let transfer_result = sample_download_progress( - game_id, + &game_id, progress_tracker, tx_notify_ui.clone(), download_transfer_chunks(&transfer_ctx, &transfer_descs), @@ -96,14 +93,14 @@ pub async fn download_game_files( if let Err(err) = transfer_result { rollback_version_ini_transaction(&game_root).await; if cancel_token.is_cancelled() { - discard_cancelled_download_best_effort(&games_folder, game_id).await; + discard_cancelled_download_best_effort(&games_folder, &game_id).await; } return Err(err); } if cancel_token.is_cancelled() { rollback_version_ini_transaction(&game_root).await; - discard_cancelled_download_best_effort(&games_folder, game_id).await; + discard_cancelled_download_best_effort(&games_folder, &game_id).await; eyre::bail!("download cancelled for game {game_id}"); } diff --git a/crates/lanspread-peer/src/download/storage.rs b/crates/lanspread-peer/src/download/storage.rs index 11992c4..d8c7d70 100644 --- a/crates/lanspread-peer/src/download/storage.rs +++ b/crates/lanspread-peer/src/download/storage.rs @@ -1,25 +1,18 @@ use std::{io::ErrorKind, path::Path}; -use lanspread_db::db::GameFileDescription; use tokio::fs::OpenOptions; -use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path}; +use super::manifest::ValidatedDownloadManifest; +use crate::local_games::is_local_dir_name; const SYNC_DIR: &str = ".sync"; /// Prepares storage for game files by creating directories and pre-allocating files. -pub(super) async fn prepare_game_storage( - games_folder: &Path, - file_descs: &[GameFileDescription], -) -> eyre::Result<()> { - for desc in file_descs { - if desc.is_version_ini() { - continue; - } +pub(super) async fn prepare_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> { + for entry in manifest.transfer_entries() { + let validated_path = manifest.game_root().join(entry.canonical_path()); - let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?; - - if desc.is_dir { + if entry.is_dir() { tokio::fs::create_dir_all(&validated_path).await?; } else { if let Some(parent) = validated_path.parent() { @@ -33,18 +26,18 @@ pub(super) async fn prepare_game_storage( .open(&validated_path) .await?; - let size = desc.size; + let size = entry.size(); if let Err(e) = file.set_len(size).await { log::warn!( "Failed to pre-allocate file {} (size: {}): {}", - desc.relative_path, + entry.canonical_path(), size, e ); } else { log::debug!( "Pre-allocated file {} with {} bytes", - desc.relative_path, + entry.canonical_path(), size ); } @@ -148,7 +141,7 @@ async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result Self { + Self { + expected, + received: 0, + } + } + + fn accept(&mut self, byte_count: usize) -> eyre::Result<()> { + let byte_count = u64::try_from(byte_count)?; + self.received = self + .received + .checked_add(byte_count) + .ok_or_else(|| eyre::eyre!("received chunk byte count overflow"))?; + if self.received > self.expected { + eyre::bail!( + "peer sent too many chunk bytes: expected {}, received at least {}", + self.expected, + self.received + ); + } + Ok(()) + } + + fn finish(self) -> eyre::Result<()> { + if self.received != self.expected { + eyre::bail!( + "incomplete chunk download: expected {} bytes, received {}", + self.expected, + self.received + ); + } + Ok(()) + } +} + async fn open_chunk_stream( conn: &mut Connection, game_id: &str, @@ -84,41 +126,21 @@ async fn receive_chunk( .open(&validated_path) .await?; if chunk.length == 0 && chunk.offset == 0 { - // fallback-to-whole-file path replaces any existing partial data + // A manifest-declared empty file replaces any existing partial data. file.set_len(0).await?; } file.seek(std::io::SeekFrom::Start(chunk.offset)).await?; - let mut remaining = chunk.length; - let mut received_bytes = 0u64; + let mut receive_budget = ReceiveBudget::new(chunk.length); let mut progress = progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length); while let Some(bytes) = rx.receive().await? { + receive_budget.accept(bytes.len())?; file.write_all(&bytes).await?; progress.record_bytes(bytes.len()); - let byte_count = u64::try_from(bytes.len()).unwrap_or(u64::MAX); - received_bytes = received_bytes.saturating_add(byte_count); - - if remaining == 0 { - continue; - } - remaining = remaining.saturating_sub(byte_count); - if remaining == 0 { - break; - } - } - - // Verify we received the expected amount of data - if chunk.length > 0 && received_bytes != chunk.length { - eyre::bail!( - "Incomplete chunk download: expected {} bytes, received {} bytes for file {} at offset {}", - chunk.length, - received_bytes, - chunk.relative_path, - chunk.offset - ); } + receive_budget.finish()?; file.flush().await?; @@ -159,22 +181,16 @@ async fn download_version_ini_chunk( buffer: &VersionIniBuffer, progress_tracker: Arc, ) -> eyre::Result<()> { - let mut received = Vec::new(); + let mut received = Vec::with_capacity(usize::try_from(chunk.length)?); + let mut receive_budget = ReceiveBudget::new(chunk.length); let mut progress = progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length); while let Some(bytes) = rx.receive().await? { + receive_budget.accept(bytes.len())?; progress.record_bytes(bytes.len()); received.extend_from_slice(&bytes); } - - if chunk.length > 0 && u64::try_from(received.len())? != chunk.length { - eyre::bail!( - "Incomplete version.ini chunk download: expected {} bytes, received {} bytes at offset {}", - chunk.length, - received.len(), - chunk.offset - ); - } + receive_budget.finish()?; buffer.write_at(chunk.offset, &received).await } @@ -349,3 +365,33 @@ pub(super) async fn download_from_peer( Ok(results) } + +#[cfg(test)] +mod tests { + use super::ReceiveBudget; + + #[test] + fn receive_budget_accepts_exactly_the_requested_bytes() { + let mut budget = ReceiveBudget::new(5); + budget.accept(2).expect("first frame should fit"); + budget.accept(3).expect("second frame should fit"); + budget.finish().expect("exact byte count should finish"); + } + + #[test] + fn receive_budget_rejects_short_extra_and_nonempty_zero_length_streams() { + let mut short = ReceiveBudget::new(5); + short.accept(4).expect("partial frame should fit"); + assert!(short.finish().is_err()); + + let mut extra = ReceiveBudget::new(5); + extra.accept(5).expect("exact frame should fit"); + assert!(extra.accept(1).is_err()); + + let mut zero = ReceiveBudget::new(0); + assert!(zero.accept(1).is_err()); + ReceiveBudget::new(0) + .finish() + .expect("empty zero-length stream should finish"); + } +} diff --git a/crates/lanspread-peer/src/handlers.rs b/crates/lanspread-peer/src/handlers.rs index 290090c..cc7420f 100644 --- a/crates/lanspread-peer/src/handlers.rs +++ b/crates/lanspread-peer/src/handlers.rs @@ -17,7 +17,7 @@ use crate::{ InstallOperation, PeerEvent, context::{Ctx, OperationGuard, OperationKind}, - download::download_game_files, + download::{ValidatedDownloadManifest, download_game_files, validate_protocol_v7_descriptions}, events, install, local_games::{ @@ -217,7 +217,6 @@ pub async fn handle_download_game_files_command( ctx: &Ctx, tx_notify_ui: &UnboundedSender, id: String, - file_descriptions: Vec, install_after_download: bool, ) { log::info!("Got PeerCommand::DownloadGameFiles"); @@ -232,13 +231,43 @@ pub async fn handle_download_game_files_command( let games_folder = { ctx.game_dir.read().await.clone() }; let expected_version = catalog_expected_version(ctx, &id).await; - // Use majority validation to get trusted file descriptions and peer whitelist + let raw_peer_manifests = ctx + .peer_game_db + .read() + .await + .expected_version_game_files_for(&id, expected_version.as_deref()); + let validation_game_id = id.clone(); + let raw_validation = tokio::task::spawn_blocking(move || { + let mut valid = Vec::new(); + let mut rejected = Vec::new(); + for (peer_addr, descriptions) in raw_peer_manifests { + match validate_protocol_v7_descriptions(&validation_game_id, descriptions) { + Ok(descriptions) => valid.push((peer_addr, descriptions)), + Err(error) => rejected.push((peer_addr, error.to_string())), + } + } + (valid, rejected) + }) + .await; + let (peer_manifests, rejected_manifests) = match raw_validation { + Ok(result) => result, + Err(error) => { + log::error!("Peer manifest validation task failed for {id}: {error}"); + send_download_failed(tx_notify_ui, &id); + return; + } + }; + for (peer_addr, error) in rejected_manifests { + log::warn!("Ignoring invalid download manifest from {peer_addr} for {id}: {error}"); + } + + // Use only complete, individually valid peer manifests for size consensus. let (validated_descriptions, peer_whitelist, file_peer_map) = { match ctx .peer_game_db .read() .await - .validate_file_sizes_majority(&id, expected_version.as_deref()) + .validate_file_sizes_majority_from(&id, &peer_manifests) { Ok((files, peers, file_peer_map)) => { log::info!( @@ -260,24 +289,6 @@ pub async fn handle_download_game_files_command( } }; - let resolved_descriptions = if file_descriptions.is_empty() { - 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 validated file descriptions available to download game {id}; request metadata first" - ); - if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) { - log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); - } - return; - } - let local_dl_available = { let active_operations = ctx.active_operations.read().await; let catalog = ctx.catalog.read().await; @@ -310,6 +321,42 @@ pub async fn handle_download_game_files_command( return; } + if validated_descriptions.is_empty() { + log::error!( + "No validated file descriptions available to download game {id}; request metadata first" + ); + if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) { + log::error!("Failed to send DownloadGameFilesFailed event: {send_err}"); + } + return; + } + + let catalog = ctx.catalog.read().await.clone(); + let manifest_games_folder = games_folder.clone(); + let manifest_game_id = id.clone(); + let manifest = tokio::task::spawn_blocking(move || { + ValidatedDownloadManifest::from_protocol_v7( + &manifest_games_folder, + &manifest_game_id, + validated_descriptions, + &catalog, + ) + }) + .await; + let manifest = match manifest { + Ok(Ok(manifest)) => manifest, + Ok(Err(error)) => { + log::error!("Rejected download manifest for {id}: {error}"); + send_download_failed(tx_notify_ui, &id); + return; + } + Err(error) => { + log::error!("Download manifest validation task failed for {id}: {error}"); + send_download_failed(tx_notify_ui, &id); + return; + } + }; + match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await { BeginOperationResult::Started => {} BeginOperationResult::AlreadyActive => { @@ -344,9 +391,7 @@ pub async fn handle_download_game_files_command( ); let result = download_game_files( - &download_id, - resolved_descriptions, - games_folder, + manifest, peer_whitelist, file_peer_map, tx_notify_ui_clone.clone(), diff --git a/crates/lanspread-peer/src/lib.rs b/crates/lanspread-peer/src/lib.rs index 663a438..d510632 100644 --- a/crates/lanspread-peer/src/lib.rs +++ b/crates/lanspread-peer/src/lib.rs @@ -238,14 +238,10 @@ pub enum PeerCommand { /// Request the latest peer-advertised file details for an update. FetchLatestFromPeers { id: String }, /// Download game files. - DownloadGameFiles { - id: String, - file_descriptions: Vec, - }, + DownloadGameFiles { id: String }, /// Download game files with an explicit install policy. DownloadGameFilesWithOptions { id: String, - file_descriptions: Vec, install_after_download: bool, }, /// Stream archive-expanded bytes directly into `local/` without keeping root archives. @@ -458,26 +454,15 @@ async fn handle_peer_commands( handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LatestPeersOnly) .await; } - PeerCommand::DownloadGameFiles { - id, - file_descriptions, - } => { - handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true) - .await; + PeerCommand::DownloadGameFiles { id } => { + handle_download_game_files_command(ctx, tx_notify_ui, id, true).await; } PeerCommand::DownloadGameFilesWithOptions { id, - file_descriptions, install_after_download, } => { - handle_download_game_files_command( - ctx, - tx_notify_ui, - id, - file_descriptions, - install_after_download, - ) - .await; + handle_download_game_files_command(ctx, tx_notify_ui, id, install_after_download) + .await; } PeerCommand::StreamInstallGame { id } => { handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id).await; diff --git a/crates/lanspread-peer/src/local_games.rs b/crates/lanspread-peer/src/local_games.rs index 7b938c1..31d8c0c 100644 --- a/crates/lanspread-peer/src/local_games.rs +++ b/crates/lanspread-peer/src/local_games.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, HashSet}, hash::{Hash, Hasher}, io::ErrorKind, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::LazyLock, time::{SystemTime, UNIX_EPOCH}, }; @@ -353,6 +353,29 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool { false } +fn canonical_protocol_path(path: &Path) -> eyre::Result { + let mut components = Vec::new(); + for component in path.components() { + let Component::Normal(component) = component else { + eyre::bail!("local game path is not canonical: {}", path.display()); + }; + let component = component + .to_str() + .ok_or_else(|| eyre::eyre!("local game path is not valid UTF-8: {}", path.display()))?; + if component.contains('\\') { + eyre::bail!( + "local game path cannot be represented portably: {}", + path.display() + ); + } + components.push(component); + } + if components.is_empty() { + eyre::bail!("local game path cannot be empty"); + } + Ok(components.join("/")) +} + async fn scan_game_descriptions( game_id: &str, game_dir: &Path, @@ -375,7 +398,7 @@ async fn scan_game_descriptions( .filter_map(std::result::Result::ok) { let relative_path = match entry.path().strip_prefix(base_dir) { - Ok(path) => path.to_string_lossy().to_string(), + Ok(path) => canonical_protocol_path(path).map_err(PeerError::Other)?, Err(e) => { log::error!( "Failed to get relative path for {}: {}", @@ -730,6 +753,15 @@ mod tests { std::fs::write(path, bytes).expect("file should be written"); } + #[test] + fn protocol_paths_always_use_forward_slashes() { + let native = PathBuf::from("game").join("nested").join("archive.eti"); + assert_eq!( + canonical_protocol_path(&native).expect("native path should convert"), + "game/nested/archive.eti" + ); + } + fn test_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex { LibraryIndex { revision, diff --git a/crates/lanspread-peer/src/peer_db.rs b/crates/lanspread-peer/src/peer_db.rs index 0d66beb..8405d1b 100644 --- a/crates/lanspread-peer/src/peer_db.rs +++ b/crates/lanspread-peer/src/peer_db.rs @@ -650,11 +650,20 @@ impl PeerGameDB { expected_version: Option<&str>, ) -> eyre::Result { let game_files = self.expected_version_game_files_for(game_id, expected_version); + self.validate_file_sizes_majority_from(game_id, &game_files) + } + + /// Validates file-size consensus over caller-sanitized complete peer manifests. + pub(crate) fn validate_file_sizes_majority_from( + &self, + game_id: &str, + game_files: &[(SocketAddr, Vec)], + ) -> eyre::Result { if game_files.is_empty() { return Ok((Vec::new(), Vec::new(), HashMap::new())); } - let (file_size_map, _peer_files) = collect_file_sizes(&game_files); + let (file_size_map, _peer_files) = collect_file_sizes(game_files); let (validated_files, peer_scores, file_peer_map) = self.validate_each_file_consensus(game_id, file_size_map)?; let peer_whitelist = create_peer_whitelist(peer_scores); @@ -835,7 +844,7 @@ fn collect_file_sizes( for (peer_addr, files) in game_files { let mut peer_file_sizes = HashMap::new(); for file in files { - if !file.is_dir { + if !file.is_dir && !peer_file_sizes.contains_key(&file.relative_path) { let size = file.size; file_size_map .entry(file.relative_path.clone()) @@ -1160,4 +1169,53 @@ mod tests { assert_eq!(archive.size, 20); assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr])); } + + #[test] + fn duplicate_entries_from_one_peer_do_not_manufacture_consensus() { + let attacker = addr(12005); + let honest_a = addr(12006); + let honest_b = addr(12007); + let mut db = PeerGameDB::new(); + for (peer_id, peer_addr) in [ + ("attacker", attacker), + ("honest-a", honest_a), + ("honest-b", honest_b), + ] { + let peer_id = peer_id.to_owned(); + db.upsert_peer(peer_id.clone(), peer_addr); + db.update_peer_games( + &peer_id, + vec![summary("game", "20250101", Availability::Ready)], + ); + } + + let mut attacker_files = vec![file_desc("game", "game/version.ini", 8)]; + attacker_files + .extend(std::iter::repeat_with(|| file_desc("game", "game/archive.eti", 99)).take(10)); + let honest_files = vec![ + file_desc("game", "game/version.ini", 8), + file_desc("game", "game/archive.eti", 20), + ]; + db.update_peer_game_files(&"attacker".to_owned(), "game", attacker_files.clone()); + db.update_peer_game_files(&"honest-a".to_owned(), "game", honest_files.clone()); + db.update_peer_game_files(&"honest-b".to_owned(), "game", honest_files.clone()); + + let manifests = vec![ + (attacker, attacker_files), + (honest_a, honest_files.clone()), + (honest_b, honest_files), + ]; + let (validated, _, file_peer_map) = db + .validate_file_sizes_majority_from("game", &manifests) + .expect("honest peers should determine consensus"); + let archive = validated + .iter() + .find(|description| description.relative_path == "game/archive.eti") + .expect("archive should validate"); + assert_eq!(archive.size, 20); + assert_eq!( + file_peer_map.get("game/archive.eti"), + Some(&vec![honest_a, honest_b]) + ); + } } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 884201c..e64aadc 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -14,7 +14,7 @@ use std::{ use eyre::bail; use lanspread_compat::eti::get_games; -use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription}; +use lanspread_db::db::{Availability, Game, GameCatalog, GameDB}; use lanspread_peer::{ ActiveOperation, ActiveOperationKind, @@ -2249,9 +2249,9 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } PeerEvent::GotGameFiles { id, - file_descriptions, + file_descriptions: _, } => { - handle_got_game_files(app_handle, id, file_descriptions).await; + handle_got_game_files(app_handle, id).await; } PeerEvent::NoPeersHaveGame { id } => { log::warn!("PeerEvent::NoPeersHaveGame received for {id}"); @@ -2382,20 +2382,13 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) { } } -async fn handle_got_game_files( - app_handle: &AppHandle, - id: String, - file_descriptions: Vec, -) { +async fn handle_got_game_files(app_handle: &AppHandle, id: String) { log::info!("PeerEvent::GotGameFiles received"); let state = app_handle.state::(); let peer_ctrl = state.peer_ctrl.read().await.clone(); if let Some(peer_ctrl) = peer_ctrl - && let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { - id, - file_descriptions, - }) + && let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { id }) { log::error!("Failed to continue queued game transfer: {e}"); } diff --git a/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md b/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md new file mode 100644 index 0000000..e390398 --- /dev/null +++ b/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md @@ -0,0 +1,71 @@ +# Peer authentication refactor decisions + +This file records implementation choices that are not fully determined by +`PEER_AUTH_PLAN.md`, especially choices where every available option has a +meaningful downside. + +## 2026-08-09 — Bound protocol-7 download descriptions generously + +**TL;DR:** Until catalog manifests replace remote descriptions, accept at most +100,000 entries, 1 TiB per ordinary file, 64 KiB for the in-memory `version.ini` +sentinel, 255 bytes per path component, 900 bytes per relative path, and 16 TiB +in aggregate for one game. Resolved destinations are capped at 1,000 platform +path units. + +The plan requires count, per-file, and aggregate limits but intentionally does +not prescribe values. These limits are far above the expected game and CLI +fixture sizes while bounding allocation, planning work, and arithmetic. + +Alternatives: + +- Lower product-sized limits would reject abusive inputs earlier, but risk + inventing constraints that real large games or mod packs exceed. +- Limits near `usize`/`u64` maxima avoid practical compatibility concerns, but + do not meaningfully bound memory or work. +- Configurable limits add settings and test combinations without a current user + need; protocol safety limits should be consistent between installations. + +## 2026-08-09 — Track download ownership; never infer it from unknown files + +**TL;DR:** Persist an atomic per-game set of successfully downloaded paths under +application state. On replacement, remove only paths in the previous owned set +that are absent from the new authoritative manifest. If old or corrupt state has +no trustworthy set, preserve unknown root files. + +The existing game directory has no provenance information. Deleting every +non-reserved path would remove stale downloads, but could also delete a user's +own files and contradict the plan's preservation rule. The first update after +upgrading may therefore leave legacy stale files; subsequent successful +downloads have exact ownership and can clean safely. + +Alternatives: + +- Bootstrap ownership from the current directory. This is seamless, but can + permanently misclassify user files as Lanspread-owned and later delete them. +- Delete every non-reserved file missing from the new manifest. This is simple + and cleans legacy state completely, but is unsafe for user files. +- Delete only familiar archive suffixes such as `.eti`. This avoids most user + files, but bakes package naming guesses into the security boundary and cannot + clean general catalog payloads. +- Never clean stale files. This is safest for user data, but fails the explicit + successful-replacement requirement and can keep serving obsolete payload. + +## 2026-08-09 — Generate production manifests outside the source checkout + +**TL;DR:** Provide a small deterministic generator binary that runs next to the +canonical production packages and writes the JSON manifest artifacts that are +then stored and packaged with Lanspread. + +This checkout has catalog metadata for 186 games but canonical package bytes +only for a small CLI fixture set. Hashes cannot be derived honestly without the +bytes. Keeping the generator here makes the format reproducible without +requiring production game packages in Git. + +Alternatives: + +- Store production packages in this repository. That would make generation + self-contained, but is impractical for size and distribution reasons. +- Generate hashes at application runtime. This would make local peer bytes the + authority, defeating catalog-owned verification and adding startup work. +- Accept hashes announced by peers. This is easy to deploy, but provides no + protection against a peer that supplies both the bytes and their claimed hash.