use std::{ collections::BTreeMap, fs::Metadata, path::{Path, PathBuf}, }; use eyre::WrapErr; use lanspread_db::db::{GameCatalog, GameFileDescription}; use crate::game_paths::{VERSION_INI, is_download_protected_root_name, portable_name_key}; /// 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; /// 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_download_protected_root_name(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_download_protected_root_name(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(portable_name_key(component)) } 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 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()); } }