diff --git a/crates/lanspread-peer/src/local_games.rs b/crates/lanspread-peer/src/local_games.rs index c6e42a9..74dcc8f 100644 --- a/crates/lanspread-peer/src/local_games.rs +++ b/crates/lanspread-peer/src/local_games.rs @@ -3,13 +3,22 @@ use std::{ collections::{HashMap, HashSet}, fs::Metadata, - io::{ErrorKind, Write as _}, + io::{ErrorKind, Read as _, Write as _}, path::{Path, PathBuf}, sync::LazyLock, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use lanspread_db::db::{Availability, Game, GameCatalog, GameDB}; +use lanspread_db::{ + content_manifest::{ + MAX_CATALOG_ENTRIES, + MAX_CATALOG_MANIFEST_BYTES, + MAX_CATALOG_PATH_BYTES, + MAX_CATALOG_TOTAL_BYTES, + }, + db::{Availability, Game, GameCatalog, GameDB}, +}; +use lanspread_proto::MAX_LIBRARY_GAMES; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; @@ -151,7 +160,7 @@ pub async fn local_download_matches_catalog( }; let game_path = game_dir.join(game_id); - let local_version = scoped_blocking(|| lanspread_db::db::read_version_from_ini(&game_path)); + let local_version = scoped_blocking(|| read_version_from_ini_bounded(&game_path)); match local_version { Ok(Some(local_version)) if local_version == expected_version => true, Ok(Some(local_version)) => { @@ -175,6 +184,82 @@ pub async fn local_download_matches_catalog( // ============================================================================= const LIBRARY_INDEX_FILE: &str = "library_index.json"; +const MAX_LOCAL_LIBRARY_INDEX_BYTES: u64 = MAX_CATALOG_MANIFEST_BYTES; +const MAX_LOCAL_INDEX_ETI_FILES: usize = MAX_CATALOG_ENTRIES; +const MAX_SELECTED_ROOT_ENTRIES: usize = MAX_CATALOG_ENTRIES; +// A catalog can contain 100,000 explicit paths. Allow ample room for implicit +// parents and user-owned installed files without making one selected root an +// unbounded recursive workload. +const MAX_LOCAL_GAME_TREE_ENTRIES: usize = MAX_CATALOG_ENTRIES * 10 + 1; +// With non-empty components, a 900-byte canonical catalog path can contain at +// most 450 components. Reusing that ceiling avoids rejecting catalog-shaped +// installs while bounding traversal stacks for locally added content. +const MAX_LOCAL_GAME_TREE_DEPTH: usize = MAX_CATALOG_PATH_BYTES.div_ceil(2); +const MAX_LOCAL_SCAN_DURATION: Duration = Duration::from_mins(10); +const MAX_VERSION_INI_BYTES: u64 = 64 * 1024; + +#[derive(Clone, Copy)] +struct TraversalLimits { + entry_cap: usize, + depth_ceiling: usize, + time_budget: Duration, +} + +impl TraversalLimits { + const LOCAL_GAME_TREE: Self = Self { + entry_cap: MAX_LOCAL_GAME_TREE_ENTRIES, + depth_ceiling: MAX_LOCAL_GAME_TREE_DEPTH, + time_budget: MAX_LOCAL_SCAN_DURATION, + }; + + const SELECTED_ROOT: Self = Self { + entry_cap: MAX_SELECTED_ROOT_ENTRIES, + depth_ceiling: 1, + time_budget: MAX_LOCAL_SCAN_DURATION, + }; +} + +struct TraversalBudget { + limits: TraversalLimits, + entries: usize, + started: Instant, +} + +impl TraversalBudget { + fn new(limits: TraversalLimits) -> Self { + Self { + limits, + entries: 0, + started: Instant::now(), + } + } + + fn charge(&mut self, depth: usize, context: &str) -> eyre::Result<()> { + self.entries = self + .entries + .checked_add(1) + .ok_or_else(|| eyre::eyre!("{context} entry count overflow"))?; + if self.entries > self.limits.entry_cap { + eyre::bail!( + "{context} exceeds the {}-entry scan budget", + self.limits.entry_cap + ); + } + if depth > self.limits.depth_ceiling { + eyre::bail!( + "{context} exceeds the {}-component depth budget", + self.limits.depth_ceiling + ); + } + if self.started.elapsed() >= self.limits.time_budget { + eyre::bail!( + "{context} exceeds the {:?} scan-time budget", + self.limits.time_budget + ); + } + Ok(()) + } +} #[derive(Debug, Clone, Copy, Default)] struct LibraryIndexRuntimeState { @@ -193,10 +278,17 @@ struct LibraryIndex { /// Converts a legacy selected-root cache into app-owned state without carrying /// its stale revision authority across the trust boundary. -pub(crate) fn normalize_migrated_library_index(bytes: &[u8]) -> serde_json::Result> { +pub(crate) fn normalize_migrated_library_index(bytes: &[u8]) -> eyre::Result> { let mut index: LibraryIndex = serde_json::from_slice(bytes)?; + validate_library_index_budget(&index)?; index.revision = 0; - serde_json::to_vec(&index) + let normalized = serde_json::to_vec(&index)?; + if u64::try_from(normalized.len()).unwrap_or(u64::MAX) > MAX_LOCAL_LIBRARY_INDEX_BYTES { + eyre::bail!( + "normalized local library index exceeds the {MAX_LOCAL_LIBRARY_INDEX_BYTES}-byte limit" + ); + } + Ok(normalized) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -273,39 +365,97 @@ fn sweep_stale_library_index_tmp(path: &Path) { } } -fn load_library_index(path: &Path) -> LibraryIndex { +fn empty_library_index() -> LibraryIndex { + LibraryIndex { + revision: 0, + games: HashMap::new(), + } +} + +fn validate_library_index_budget(index: &LibraryIndex) -> eyre::Result<()> { + if index.games.len() > MAX_LIBRARY_GAMES { + eyre::bail!("local library index exceeds the {MAX_LIBRARY_GAMES}-game publication limit"); + } + let eti_files = index.games.values().try_fold(0_usize, |total, game| { + total + .checked_add(game.fingerprint.eti_files.len()) + .ok_or_else(|| eyre::eyre!("local library index ETI count overflow")) + })?; + if eti_files > MAX_LOCAL_INDEX_ETI_FILES { + eyre::bail!( + "local library index exceeds the {MAX_LOCAL_INDEX_ETI_FILES}-ETI fingerprint limit" + ); + } + Ok(()) +} + +fn read_bounded_regular_file(path: &Path, limit: u64) -> std::io::Result>> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !metadata.is_file() || is_link_or_reparse(&metadata) { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + format!("{} is not a non-link regular file", path.display()), + )); + } + if metadata.len() > limit { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + format!("{} exceeds the {limit}-byte read limit", path.display()), + )); + } + + let file = std::fs::File::open(path)?; + let opened = file.metadata()?; + if !opened.is_file() || opened.len() > limit { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + format!("{} changed while opening", path.display()), + )); + } + let capacity = usize::try_from(opened.len()) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?; + let mut bytes = Vec::with_capacity(capacity); + file.take(limit + 1).read_to_end(&mut bytes)?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + format!("{} exceeds the {limit}-byte read limit", path.display()), + )); + } + Ok(Some(bytes)) +} + +fn load_library_index(path: &Path) -> eyre::Result { sweep_stale_library_index_tmp(path); - let data = match std::fs::read_to_string(path) { - Ok(data) => data, - Err(err) => { - if err.kind() != ErrorKind::NotFound { - log::warn!("Failed to read library index {}: {err}", path.display()); - } - return LibraryIndex { - revision: 0, - games: HashMap::new(), - }; - } + let Some(data) = read_bounded_regular_file(path, MAX_LOCAL_LIBRARY_INDEX_BYTES)? else { + return Ok(empty_library_index()); }; - match serde_json::from_str(&data) { - Ok(index) => index, + match serde_json::from_slice(&data) { + Ok(index) => { + validate_library_index_budget(&index)?; + Ok(index) + } Err(err) => { log::warn!("Failed to parse library index {}: {err}", path.display()); - LibraryIndex { - revision: 0, - games: HashMap::new(), - } + Ok(empty_library_index()) } } } -fn load_library_index_with_floor(path: &Path, revision_floor: u64) -> (LibraryIndex, bool) { - let mut index = load_library_index(path); +fn load_library_index_with_floor( + path: &Path, + revision_floor: u64, +) -> eyre::Result<(LibraryIndex, bool)> { + let mut index = load_library_index(path)?; let revision_regressed = index.revision < revision_floor; index.revision = index.revision.max(revision_floor); - (index, revision_regressed) + Ok((index, revision_regressed)) } fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()> { @@ -317,6 +467,7 @@ fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()> } fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> { + validate_library_index_budget(index)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; // Persist `local_library/` in the state directory. Repeating this on @@ -324,6 +475,9 @@ fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> { sync_parent_dir(parent)?; } let data = serde_json::to_vec_pretty(index)?; + if u64::try_from(data.len()).unwrap_or(u64::MAX) > MAX_LOCAL_LIBRARY_INDEX_BYTES { + eyre::bail!("local library index exceeds the {MAX_LOCAL_LIBRARY_INDEX_BYTES}-byte limit"); + } let tmp_path = library_index_tmp_path(path); let mut file = std::fs::File::create(&tmp_path)?; @@ -369,8 +523,10 @@ fn root_eti_fingerprints(game_path: &Path) -> eyre::Result> }; let mut eti_files = Vec::new(); + let mut budget = TraversalBudget::new(TraversalLimits::SELECTED_ROOT); for entry in entries { let entry = entry?; + budget.charge(1, "game-root fingerprint")?; let metadata = std::fs::symlink_metadata(entry.path())?; if is_link_or_reparse(&metadata) { eyre::bail!( @@ -411,8 +567,19 @@ fn fingerprint_game_dir( let version_path = game_path.join(VERSION_INI); let (version_mtime, version_contents) = match std::fs::symlink_metadata(&version_path) { Ok(metadata) if metadata.is_file() && !is_link_or_reparse(&metadata) => { - let contents = match std::fs::read_to_string(&version_path) { - Ok(contents) => Some(contents.trim().to_string()), + let contents = match read_bounded_regular_file(&version_path, MAX_VERSION_INI_BYTES) { + Ok(Some(contents)) => match String::from_utf8(contents) { + Ok(contents) => Some(contents.trim().to_string()), + Err(err) => { + log::warn!( + "Failed to decode {} for fingerprinting: {err}", + version_path.display() + ); + None + } + }, + Ok(None) => None, + Err(err) if err.kind() == ErrorKind::InvalidData => return Err(err.into()), Err(err) => { log::warn!( "Failed to read {} for fingerprinting: {err}", @@ -450,9 +617,20 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool { } fn validate_game_tree_shape(game_path: &Path) -> eyre::Result<()> { - let mut entries = walkdir::WalkDir::new(game_path).into_iter(); + validate_game_tree_shape_with_limits(game_path, TraversalLimits::LOCAL_GAME_TREE) +} + +fn validate_game_tree_shape_with_limits( + game_path: &Path, + limits: TraversalLimits, +) -> eyre::Result<()> { + let mut budget = TraversalBudget::new(limits); + let mut entries = walkdir::WalkDir::new(game_path) + .max_depth(limits.depth_ceiling.saturating_add(1)) + .into_iter(); while let Some(entry) = entries.next() { let entry = entry?; + budget.charge(entry.depth(), "local game tree")?; if should_skip_root_entry(&entry) { entries.skip_current_dir(); continue; @@ -473,6 +651,14 @@ fn validate_game_tree_shape(game_path: &Path) -> eyre::Result<()> { } fn scan_game_size(game_id: &str, game_dir: &Path) -> Result { + scan_game_size_with_limits(game_id, game_dir, TraversalLimits::LOCAL_GAME_TREE) +} + +fn scan_game_size_with_limits( + game_id: &str, + game_dir: &Path, + limits: TraversalLimits, +) -> Result { let game_path = game_dir.join(game_id); if !direct_game_root_is_safe_directory_sync(&game_path) { @@ -483,9 +669,15 @@ fn scan_game_size(game_id: &str, game_dir: &Path) -> Result { } let mut total_size = 0_u64; - let mut entries = walkdir::WalkDir::new(&game_path).into_iter(); + let mut budget = TraversalBudget::new(limits); + let mut entries = walkdir::WalkDir::new(&game_path) + .max_depth(limits.depth_ceiling.saturating_add(1)) + .into_iter(); while let Some(entry) = entries.next() { let entry = entry.map_err(|error| PeerError::Other(error.into()))?; + budget + .charge(entry.depth(), "local game size scan") + .map_err(PeerError::Other)?; if should_skip_root_entry(&entry) { entries.skip_current_dir(); continue; @@ -515,6 +707,11 @@ fn scan_game_size(game_id: &str, game_dir: &Path) -> Result { total_size = total_size .checked_add(metadata.len()) .ok_or_else(|| PeerError::Other(eyre::eyre!("local game size exceeds u64")))?; + if total_size > MAX_CATALOG_TOTAL_BYTES { + return Err(PeerError::Other(eyre::eyre!( + "local game size exceeds the {MAX_CATALOG_TOTAL_BYTES}-byte scan budget" + ))); + } } } @@ -534,7 +731,7 @@ fn build_game_summary( let installed = !runtime_recovery_failed && local_dir_is_directory_sync(&game_path); let eti_version = if downloaded { - match lanspread_db::db::read_version_from_ini(&game_path) { + match read_version_from_ini_bounded(&game_path) { Ok(version) => version, Err(e) => { log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}"); @@ -563,6 +760,21 @@ fn build_game_summary( }) } +fn read_version_from_ini_bounded(game_path: &Path) -> eyre::Result> { + let version_path = game_path.join(VERSION_INI); + let Some(bytes) = read_bounded_regular_file(&version_path, MAX_VERSION_INI_BYTES)? else { + return Ok(None); + }; + let content = String::from_utf8(bytes)?; + let version = content.trim(); + if version.len() == 8 && version.chars().all(|character| character.is_ascii_digit()) { + Ok(Some(version.to_owned())) + } else { + log::warn!("Invalid version format in {}", version_path.display()); + Ok(None) + } +} + pub(crate) fn game_from_summary(summary: &LocalGameSummary) -> Game { Game { id: summary.id.clone(), @@ -674,7 +886,7 @@ fn clear_index_and_scan_empty( ) -> eyre::Result { let index_path = library_index_path(state_dir); let (mut index, revision_regressed) = - load_library_index_with_floor(&index_path, runtime_state.revision_floor); + load_library_index_with_floor(&index_path, runtime_state.revision_floor)?; if revision_regressed || runtime_state.rewrite_required || !index.games.is_empty() { index.games.clear(); advance_library_index_revision(&mut index)?; @@ -725,13 +937,25 @@ fn settle_library_scan( } } +#[derive(Debug)] enum GameDirectoryDiscovery { Missing, Unsafe, Games(Vec), } -fn discover_game_ids(game_dir: &Path) -> eyre::Result { +fn discover_game_ids( + game_dir: &Path, + catalog: &GameCatalog, +) -> eyre::Result { + discover_game_ids_with_limits(game_dir, catalog, TraversalLimits::SELECTED_ROOT) +} + +fn discover_game_ids_with_limits( + game_dir: &Path, + catalog: &GameCatalog, + limits: TraversalLimits, +) -> eyre::Result { let metadata = match std::fs::symlink_metadata(game_dir) { Ok(metadata) => metadata, Err(err) if err.kind() == ErrorKind::NotFound => { @@ -745,21 +969,30 @@ fn discover_game_ids(game_dir: &Path) -> eyre::Result { } let mut game_ids = Vec::new(); + let mut budget = TraversalBudget::new(limits); for entry in std::fs::read_dir(game_dir)? { let entry = entry?; + budget.charge(1, "selected game directory")?; + let Some(game_id) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if is_ignored_games_root_name(&game_id) || !catalog.contains(&game_id) { + continue; + } let path = entry.path(); let metadata = std::fs::symlink_metadata(&path)?; if !metadata.is_dir() || is_link_or_reparse(&metadata) { continue; } - let Some(game_id) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !is_ignored_games_root_name(game_id) { - game_ids.push(game_id.to_string()); + game_ids.push(game_id); + if game_ids.len() > MAX_LIBRARY_GAMES { + eyre::bail!( + "selected game directory exceeds the {MAX_LIBRARY_GAMES}-game publication limit" + ); } } + game_ids.sort(); Ok(GameDirectoryDiscovery::Games(game_ids)) } @@ -801,13 +1034,19 @@ fn scan_discovered_games( let index_path = library_index_path(state_dir); let (mut index, revision_regressed) = - load_library_index_with_floor(&index_path, runtime_state.revision_floor); + load_library_index_with_floor(&index_path, runtime_state.revision_floor)?; let mut seen_ids = HashSet::new(); let mut summaries = HashMap::new(); let mut games = Vec::new(); let mut changed = revision_regressed || runtime_state.rewrite_required; + let scan_started = Instant::now(); for game_id in game_ids { + if scan_started.elapsed() >= MAX_LOCAL_SCAN_DURATION { + eyre::bail!( + "local library exceeds the {MAX_LOCAL_SCAN_DURATION:?} aggregate scan-time budget" + ); + } let readiness = download_readiness .get(game_id) .copied() @@ -831,6 +1070,12 @@ fn scan_discovered_games( games.push(game_from_summary(&summary)); } + if scan_started.elapsed() >= MAX_LOCAL_SCAN_DURATION { + eyre::bail!( + "local library exceeds the {MAX_LOCAL_SCAN_DURATION:?} aggregate scan-time budget" + ); + } + let before = index.games.len(); index.games.retain(|game_id, _| seen_ids.contains(game_id)); if index.games.len() != before { @@ -861,7 +1106,7 @@ fn rescan_game( ) -> eyre::Result { let index_path = library_index_path(state_dir); let (mut index, revision_regressed) = - load_library_index_with_floor(&index_path, runtime_state.revision_floor); + load_library_index_with_floor(&index_path, runtime_state.revision_floor)?; let update = update_index_for_game( game_dir, @@ -906,7 +1151,7 @@ pub(crate) async fn scan_local_library_with_recovery_failures( let index_path = library_index_path(state_path); let mut states = LIBRARY_INDEX_STATES.lock().await; let runtime_state = states.get(&index_path).copied().unwrap_or_default(); - let discovery = scoped_blocking(|| discover_game_ids(game_path))?; + let discovery = scoped_blocking(|| discover_game_ids(game_path, catalog))?; let game_ids = match discovery { GameDirectoryDiscovery::Missing => { log::warn!( @@ -1074,6 +1319,25 @@ mod tests { ); } + #[test] + fn migrated_library_index_rejects_excess_game_entries() { + let mut template_index = test_library_index(1, "game", 1); + let template = template_index + .games + .remove("game") + .expect("template entry should exist"); + let games = (0..=MAX_LIBRARY_GAMES) + .map(|index| (format!("game-{index}"), template.clone())) + .collect(); + let oversized = LibraryIndex { revision: 9, games }; + let bytes = serde_json::to_vec(&oversized).expect("oversized index should serialize"); + + let error = normalize_migrated_library_index(&bytes) + .expect_err("legacy index above the game-entry budget must be rejected"); + + assert!(error.to_string().contains("game publication limit")); + } + #[test] fn legacy_fingerprint_defaults_download_recovery_to_false() { let fingerprint: GameFingerprint = serde_json::from_value(serde_json::json!({ @@ -1102,7 +1366,7 @@ mod tests { save_library_index(&index_path, &second).expect("replacement index write should succeed"); assert!(!tmp_path.exists()); - let loaded = load_library_index(&index_path); + let loaded = load_library_index(&index_path).expect("saved index should load"); assert_eq!(loaded.revision, 2); assert!(!loaded.games.contains_key("game-a")); let game_b = loaded @@ -1123,13 +1387,106 @@ mod tests { write_file(&index_path, &data); write_file(&tmp_path, b"{ not json"); - let loaded = load_library_index(&index_path); + let loaded = load_library_index(&index_path).expect("index should load"); assert_eq!(loaded.revision, 7); assert!(loaded.games.contains_key("game")); assert!(!tmp_path.exists()); } + #[test] + fn oversized_current_index_is_rejected_without_replacement() { + let state = TempDir::new("lanspread-local-index-budget"); + let index_path = library_index_path(state.path()); + std::fs::create_dir_all(index_path.parent().expect("index should have a parent")) + .expect("index parent should be created"); + std::fs::File::create(&index_path) + .expect("index should be created") + .set_len(MAX_LOCAL_LIBRARY_INDEX_BYTES + 1) + .expect("index should become oversized"); + + let error = load_library_index(&index_path) + .expect_err("an oversized current index must fail closed"); + + assert!(error.to_string().contains("read limit")); + assert_eq!( + std::fs::metadata(&index_path) + .expect("oversized index should remain") + .len(), + MAX_LOCAL_LIBRARY_INDEX_BYTES + 1 + ); + } + + #[test] + fn recursive_game_scans_enforce_entry_depth_and_time_budgets() { + let games = TempDir::new("lanspread-local-tree-budget"); + write_file(&games.path().join("game/nested/payload.bin"), b"payload"); + + let entry_limits = TraversalLimits { + entry_cap: 1, + depth_ceiling: MAX_LOCAL_GAME_TREE_DEPTH, + time_budget: MAX_LOCAL_SCAN_DURATION, + }; + assert!( + validate_game_tree_shape_with_limits(&games.path().join("game"), entry_limits) + .expect_err("a one-entry tree budget must reject a child") + .to_string() + .contains("entry scan budget") + ); + assert!(scan_game_size_with_limits("game", games.path(), entry_limits).is_err()); + + let depth_limits = TraversalLimits { + entry_cap: 16, + depth_ceiling: 1, + time_budget: MAX_LOCAL_SCAN_DURATION, + }; + assert!( + validate_game_tree_shape_with_limits(&games.path().join("game"), depth_limits) + .expect_err("a one-component tree budget must reject a nested file") + .to_string() + .contains("depth budget") + ); + + let time_limits = TraversalLimits { + entry_cap: 16, + depth_ceiling: MAX_LOCAL_GAME_TREE_DEPTH, + time_budget: Duration::ZERO, + }; + assert!( + validate_game_tree_shape_with_limits(&games.path().join("game"), time_limits) + .expect_err("a zero-duration tree budget must fail immediately") + .to_string() + .contains("scan-time budget") + ); + } + + #[test] + fn selected_root_discovery_filters_before_retention_and_is_bounded() { + let games = TempDir::new("lanspread-local-root-budget"); + write_file(&games.path().join("game/version.ini"), b"20250101"); + write_file(&games.path().join("unknown/version.ini"), b"20250101"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + + let GameDirectoryDiscovery::Games(discovered) = + discover_game_ids(games.path(), &catalog).expect("root discovery should succeed") + else { + panic!("existing root should be discovered"); + }; + assert_eq!(discovered, vec!["game"]); + + let error = discover_game_ids_with_limits( + games.path(), + &catalog, + TraversalLimits { + entry_cap: 1, + depth_ceiling: 1, + time_budget: MAX_LOCAL_SCAN_DURATION, + }, + ) + .expect_err("two root entries must exceed a one-entry budget"); + assert!(error.to_string().contains("entry scan budget")); + } + #[tokio::test] async fn failed_index_save_does_not_return_or_record_a_revision() { let games = TempDir::new("lanspread-local-games-save-failure"); @@ -1154,7 +1511,44 @@ mod tests { .expect("scan should succeed once durable storage is available"); assert_eq!(retry.revision, 1); - assert_eq!(load_library_index(&index_path).revision, 1); + assert_eq!( + load_library_index(&index_path) + .expect("saved index should load") + .revision, + 1 + ); + } + + #[tokio::test] + async fn oversized_version_read_keeps_the_previous_index_generation() { + let games = TempDir::new("lanspread-local-version-budget"); + let state = TempDir::new("lanspread-local-version-budget-state"); + let catalog = GameCatalog::from_ids(["game".to_string()]); + let version_path = games.path().join("game/version.ini"); + write_file(&version_path, b"20250101"); + + let initial = scan_local_library(games.path(), state.path(), &catalog) + .await + .expect("initial scan should succeed"); + std::fs::OpenOptions::new() + .write(true) + .open(&version_path) + .expect("version file should open") + .set_len(MAX_VERSION_INI_BYTES + 1) + .expect("version file should become oversized"); + + let error = rescan_local_game(games.path(), state.path(), &catalog, "game") + .await + .expect_err("oversized version input must reject the rescan"); + + assert!(error.to_string().contains("read limit")); + let persisted = load_library_index(&library_index_path(state.path())) + .expect("prior index should remain readable"); + assert_eq!(persisted.revision, initial.revision); + assert_eq!( + persisted.games["game"].summary.eti_version.as_deref(), + Some("20250101") + ); } #[tokio::test] @@ -1352,7 +1746,9 @@ mod tests { ); assert!(scan_game_size("game", games.path()).is_err()); assert_eq!( - load_library_index(&library_index_path(state.path())).revision, + load_library_index(&library_index_path(state.path())) + .expect("saved index should load") + .revision, initial.revision, "a rejected tree must not advance the durable index" ); @@ -1571,7 +1967,12 @@ mod tests { assert_eq!(rebuilt.revision, second.revision + 1); assert!(rebuilt.summaries.contains_key("game")); - assert_eq!(load_library_index(&index_path).revision, rebuilt.revision); + assert_eq!( + load_library_index(&index_path) + .expect("rebuilt index should load") + .revision, + rebuilt.revision + ); } #[tokio::test] @@ -1597,7 +1998,8 @@ mod tests { scan_a.expect("game-a rescan should succeed"); scan_b.expect("game-b rescan should succeed"); - let index = load_library_index(&library_index_path(state.path())); + let index = load_library_index(&library_index_path(state.path())) + .expect("concurrent index should load"); assert_eq!(index.revision, 3); let game_a = index .games