//! Local game scanning and database management. use std::{ collections::{HashMap, HashSet}, fs::Metadata, io::{ErrorKind, Write as _}, path::{Path, PathBuf}, sync::LazyLock, time::{SystemTime, UNIX_EPOCH}, }; use lanspread_db::db::{Availability, Game, GameCatalog, GameDB}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use crate::{ context::OperationKind, download::{DownloadOwnershipReadiness, download_ownership_readiness}, error::PeerError, game_paths::{ LEGACY_LIBRARY_INDEX_DIR, LOCAL_DIR, VERSION_INI, is_download_protected_root_name, is_ignored_games_root_name, }, library::LocalGameSummary, scoped_blocking::scoped_blocking, }; // ============================================================================= // Local directory helpers // ============================================================================= /// Checks if `local/` is a committed install directory. pub async fn local_dir_is_directory(path: &Path) -> bool { tokio::task::yield_now().await; scoped_blocking(|| local_dir_is_directory_sync(path)) } fn local_dir_is_directory_sync(path: &Path) -> bool { if !direct_game_root_is_safe_directory_sync(path) { return false; } let local_dir = path.join(LOCAL_DIR); std::fs::symlink_metadata(&local_dir) .is_ok_and(|metadata| metadata.is_dir() && !is_link_or_reparse(&metadata)) } /// Checks if the root-level `version.ini` sentinel exists as a regular file. pub async fn version_ini_is_regular_file(game_path: &Path) -> bool { tokio::task::yield_now().await; scoped_blocking(|| version_ini_is_regular_file_sync(game_path)) } fn version_ini_is_regular_file_sync(game_path: &Path) -> bool { if !direct_game_root_is_safe_directory_sync(game_path) { return false; } let version_path = game_path.join(VERSION_INI); std::fs::symlink_metadata(&version_path) .is_ok_and(|metadata| metadata.is_file() && !is_link_or_reparse(&metadata)) } /// Returns whether a direct game root can be inspected without following its /// final component. #[cfg(test)] pub(crate) async fn game_root_is_safe_directory(path: &Path) -> bool { tokio::task::yield_now().await; scoped_blocking(|| direct_game_root_is_safe_directory_sync(path)) } fn safe_directory_entry_sync(path: &Path) -> bool { std::fs::symlink_metadata(path) .is_ok_and(|metadata| metadata.is_dir() && !is_link_or_reparse(&metadata)) } fn direct_game_root_is_safe_directory_sync(path: &Path) -> bool { path.parent().is_some_and(safe_directory_entry_sync) && safe_directory_entry_sync(path) } 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 as _; 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 } /// Checks if a game is available for download locally. pub async fn local_download_available( game_dir: &Path, state_dir: &Path, game_id: &str, active_operations: &HashMap, catalog: &GameCatalog, ) -> bool { if !catalog.contains(game_id) { log::debug!("Not serving game {game_id} locally because it is not in the catalog"); return false; } if active_operations.contains_key(game_id) { log::debug!("Not serving game {game_id} locally because an operation is active"); return false; } if !scoped_blocking(|| safe_directory_entry_sync(game_dir)) { log::debug!( "Not serving game {game_id} locally because the configured game directory is unsafe" ); return false; } if download_ownership_readiness(game_dir, state_dir, game_id).await == DownloadOwnershipReadiness::RecoveryRequired { log::debug!( "Not serving game {game_id} locally because download ownership recovery is required" ); return false; } let game_path = game_dir.join(game_id); version_ini_is_regular_file(game_path.as_path()).await } /// Checks if a local game may be served to peers under the authoritative catalog version. pub async fn local_download_matches_catalog( game_dir: &Path, state_dir: &Path, game_id: &str, active_operations: &HashMap, catalog: &GameCatalog, ) -> bool { if !local_download_available(game_dir, state_dir, game_id, active_operations, catalog).await { return false; } let Some(expected_version) = catalog.expected_version(game_id) else { return true; }; let game_path = game_dir.join(game_id); let local_version = scoped_blocking(|| lanspread_db::db::read_version_from_ini(&game_path)); match local_version { Ok(Some(local_version)) if local_version == expected_version => true, Ok(Some(local_version)) => { log::debug!( "Not serving game {game_id}: local version.ini {local_version} does not match catalog {expected_version}" ); false } Ok(None) => false, Err(err) => { log::warn!( "Not serving game {game_id}: failed to read local version.ini for catalog comparison: {err}" ); false } } } // ============================================================================= // Local library index and scanning // ============================================================================= const LIBRARY_INDEX_FILE: &str = "library_index.json"; #[derive(Debug, Clone, Copy, Default)] struct LibraryIndexRuntimeState { revision_floor: u64, rewrite_required: bool, } static LIBRARY_INDEX_STATES: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Debug, Clone, Serialize, Deserialize)] struct LibraryIndex { revision: u64, games: HashMap, } /// 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> { let mut index: LibraryIndex = serde_json::from_slice(bytes)?; index.revision = 0; serde_json::to_vec(&index) } #[derive(Debug, Clone, Serialize, Deserialize)] struct GameIndexEntry { summary: LocalGameSummary, fingerprint: GameFingerprint, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct GameFingerprint { eti_files: Vec, version_mtime: Option, #[serde(default)] version_contents: Option, local_dir_present: bool, #[serde(default)] download_recovery_required: bool, #[serde(default)] runtime_recovery_failed: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct EtiFingerprint { name: String, size: u64, mtime: Option, } #[derive(Debug, Clone)] pub struct LocalLibraryScan { /// Configured games directory from which this snapshot was produced. /// /// Publication compares this value with the current configuration so a /// scan that completed after a directory switch cannot replace the new /// library state. pub source_game_dir: PathBuf, pub game_db: GameDB, pub summaries: HashMap, pub revision: u64, } pub(crate) fn legacy_library_index_path(game_dir: &Path) -> PathBuf { game_dir .join(LEGACY_LIBRARY_INDEX_DIR) .join(LIBRARY_INDEX_FILE) } fn library_index_path(state_dir: &Path) -> PathBuf { crate::state_paths::local_library_index_path(state_dir) } fn library_index_tmp_path(path: &Path) -> PathBuf { let Some(file_name) = path.file_name() else { return path.with_extension("tmp"); }; let mut tmp_name = file_name.to_os_string(); tmp_name.push(".tmp"); path.with_file_name(tmp_name) } fn sweep_stale_library_index_tmp(path: &Path) { let tmp_path = library_index_tmp_path(path); match std::fs::remove_file(&tmp_path) { Ok(()) => log::debug!( "Removed stale library index temp file {}", tmp_path.display() ), Err(err) if err.kind() == ErrorKind::NotFound => {} Err(err) => log::warn!( "Failed to remove stale library index temp file {}: {err}", tmp_path.display() ), } } fn load_library_index(path: &Path) -> LibraryIndex { 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(), }; } }; match serde_json::from_str(&data) { Ok(index) => index, Err(err) => { log::warn!("Failed to parse library index {}: {err}", path.display()); LibraryIndex { revision: 0, games: HashMap::new(), } } } } fn load_library_index_with_floor(path: &Path, revision_floor: u64) -> (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) } fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()> { index.revision = index .revision .checked_add(1) .ok_or_else(|| eyre::eyre!("local library revision overflow"))?; Ok(()) } fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; // Persist `local_library/` in the state directory. Repeating this on // replacement also repairs an uncertain prior directory-sync failure. sync_parent_dir(parent)?; } let data = serde_json::to_vec_pretty(index)?; let tmp_path = library_index_tmp_path(path); let mut file = std::fs::File::create(&tmp_path)?; file.write_all(&data)?; file.sync_all()?; drop(file); std::fs::rename(&tmp_path, path)?; sync_parent_dir(path)?; Ok(()) } #[cfg(unix)] fn sync_parent_dir(path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::File::open(parent)?.sync_all()?; } Ok(()) } #[cfg(not(unix))] fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { Ok(()) } fn system_time_to_secs(time: SystemTime) -> u64 { time.duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn is_root_eti_name(name: &str) -> bool { Path::new(name) .extension() .is_some_and(|extension| extension == "eti") } fn root_eti_fingerprints(game_path: &Path) -> eyre::Result> { let entries = match std::fs::read_dir(game_path) { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), Err(err) => return Err(err.into()), }; let mut eti_files = Vec::new(); for entry in entries { let entry = entry?; let metadata = std::fs::symlink_metadata(entry.path())?; if is_link_or_reparse(&metadata) { eyre::bail!( "local game root contains a link or reparse point: {}", entry.path().display() ); } if !metadata.is_file() { continue; } let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if !is_root_eti_name(&name) { continue; } eti_files.push(EtiFingerprint { name, size: metadata.len(), mtime: metadata.modified().ok().map(system_time_to_secs), }); } eti_files.sort_by(|a, b| a.name.cmp(&b.name)); Ok(eti_files) } fn fingerprint_game_dir( game_path: &Path, download_recovery_required: bool, runtime_recovery_failed: bool, ) -> eyre::Result { validate_game_tree_shape(game_path)?; let eti_files = root_eti_fingerprints(game_path)?; 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()), Err(err) => { log::warn!( "Failed to read {} for fingerprinting: {err}", version_path.display() ); None } }; (metadata.modified().ok().map(system_time_to_secs), contents) } Err(_) | Ok(_) => (None, None), }; let local_dir_present = local_dir_is_directory_sync(game_path); Ok(GameFingerprint { eti_files, version_mtime, version_contents, local_dir_present, download_recovery_required, runtime_recovery_failed, }) } fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool { if entry.depth() != 1 { return false; } entry .file_name() .to_str() .is_some_and(is_download_protected_root_name) } fn validate_game_tree_shape(game_path: &Path) -> eyre::Result<()> { let mut entries = walkdir::WalkDir::new(game_path).into_iter(); while let Some(entry) = entries.next() { let entry = entry?; if should_skip_root_entry(&entry) { entries.skip_current_dir(); continue; } let metadata = std::fs::symlink_metadata(entry.path())?; if is_link_or_reparse(&metadata) { if metadata.is_dir() { entries.skip_current_dir(); } eyre::bail!( "local game tree contains a link or reparse point: {}", entry.path().display() ); } } Ok(()) } fn scan_game_size(game_id: &str, game_dir: &Path) -> Result { let game_path = game_dir.join(game_id); if !direct_game_root_is_safe_directory_sync(&game_path) { return Err(PeerError::Other(eyre::eyre!( "Game directory is missing or unsafe: {}", game_path.display() ))); } let mut total_size = 0_u64; let mut entries = walkdir::WalkDir::new(&game_path).into_iter(); while let Some(entry) = entries.next() { let entry = entry.map_err(|error| PeerError::Other(error.into()))?; if should_skip_root_entry(&entry) { entries.skip_current_dir(); continue; } let metadata = match std::fs::symlink_metadata(entry.path()) { Ok(metadata) => metadata, Err(error) => { let path = entry.path().display().to_string(); log::error!("Failed to read metadata for {path}: {error}"); return Err(PeerError::FileSizeDetermination { path, source: error, }); } }; if is_link_or_reparse(&metadata) { if metadata.is_dir() { entries.skip_current_dir(); } return Err(PeerError::Other(eyre::eyre!( "Local game path is a link or reparse point: {}", entry.path().display() ))); } if metadata.is_file() { total_size = total_size .checked_add(metadata.len()) .ok_or_else(|| PeerError::Other(eyre::eyre!("local game size exceeds u64")))?; } } Ok(total_size) } fn build_game_summary( game_dir: &Path, game_id: &str, download_recovery_required: bool, runtime_recovery_failed: bool, ) -> Result { let game_path = game_dir.join(game_id); let downloaded = !download_recovery_required && !runtime_recovery_failed && version_ini_is_regular_file_sync(&game_path); 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) { Ok(version) => version, Err(e) => { log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}"); None } } } else { None }; let total_size = scan_game_size(game_id, game_dir)?; let availability = if downloaded { Availability::Ready } else { Availability::LocalOnly }; Ok(LocalGameSummary { id: game_id.to_string(), name: game_id.to_string(), size: total_size, downloaded, installed, eti_version, availability, }) } pub(crate) fn game_from_summary(summary: &LocalGameSummary) -> Game { Game { id: summary.id.clone(), name: summary.name.clone(), description: String::new(), release_year: String::new(), publisher: String::new(), max_players: 1, version: "1.0".to_string(), genre: String::new(), size: summary.size, downloaded: summary.downloaded, installed: summary.installed, availability: summary.availability.clone(), eti_game_version: summary.eti_version.clone(), local_version: summary.eti_version.clone(), peer_count: 0, } } struct IndexUpdate { summary: Option, changed: bool, } fn update_index_for_game( game_root: &Path, game_id: &str, catalog: &GameCatalog, ownership_readiness: DownloadOwnershipReadiness, runtime_recovery_failed: bool, index: &mut LibraryIndex, ) -> eyre::Result { if !catalog.contains(game_id) { return Ok(IndexUpdate { summary: None, changed: index.games.remove(game_id).is_some(), }); } let game_path = game_root.join(game_id); if !direct_game_root_is_safe_directory_sync(&game_path) { return Ok(IndexUpdate { summary: None, changed: index.games.remove(game_id).is_some(), }); } let download_recovery_required = matches!( ownership_readiness, DownloadOwnershipReadiness::RecoveryRequired ); let fingerprint = fingerprint_game_dir( &game_path, download_recovery_required, runtime_recovery_failed, )?; if fingerprint.version_mtime.is_none() && !fingerprint.local_dir_present && fingerprint.eti_files.is_empty() { return Ok(IndexUpdate { summary: None, changed: index.games.remove(game_id).is_some(), }); } let mut changed = false; let summary = match index.games.get(game_id) { Some(entry) if entry.fingerprint == fingerprint => entry.summary.clone(), _ => { changed = true; build_game_summary( game_root, game_id, download_recovery_required, runtime_recovery_failed, )? } }; index.games.insert( game_id.to_string(), GameIndexEntry { summary: summary.clone(), fingerprint, }, ); Ok(IndexUpdate { summary: Some(summary), changed, }) } fn empty_scan(game_dir: &Path, revision: u64) -> LocalLibraryScan { LocalLibraryScan { source_game_dir: game_dir.to_path_buf(), game_db: GameDB::empty(), summaries: HashMap::new(), revision, } } fn clear_index_and_scan_empty( game_dir: &Path, state_dir: &Path, runtime_state: LibraryIndexRuntimeState, ) -> 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); if revision_regressed || runtime_state.rewrite_required || !index.games.is_empty() { index.games.clear(); advance_library_index_revision(&mut index)?; save_library_index(&index_path, &index)?; } Ok(empty_scan(game_dir, index.revision)) } fn scan_from_index(index: &LibraryIndex, game_dir: &Path) -> LocalLibraryScan { let summaries = index .games .iter() .map(|(id, entry)| (id.clone(), entry.summary.clone())) .collect::>(); let games = index .games .values() .map(|entry| game_from_summary(&entry.summary)) .collect::>(); LocalLibraryScan { source_game_dir: game_dir.to_path_buf(), game_db: GameDB::from(games), summaries, revision: index.revision, } } fn settle_library_scan( states: &mut HashMap, index_path: &Path, result: eyre::Result, ) -> eyre::Result { let state = states.entry(index_path.to_path_buf()).or_default(); match result { Ok(scan) => { state.revision_floor = state.revision_floor.max(scan.revision); state.rewrite_required = false; Ok(scan) } Err(error) => { // A failed atomic save may already have renamed the new index but // failed its final directory sync. Conservatively force the next // successful scan to replace and sync a newer revision. state.rewrite_required = true; Err(error) } } } enum GameDirectoryDiscovery { Missing, Unsafe, Games(Vec), } fn discover_game_ids(game_dir: &Path) -> eyre::Result { let metadata = match std::fs::symlink_metadata(game_dir) { Ok(metadata) => metadata, Err(err) if err.kind() == ErrorKind::NotFound => { return Ok(GameDirectoryDiscovery::Missing); } Err(err) => return Err(err.into()), }; if !metadata.is_dir() || is_link_or_reparse(&metadata) { return Ok(GameDirectoryDiscovery::Unsafe); } let mut game_ids = Vec::new(); for entry in std::fs::read_dir(game_dir)? { let entry = entry?; 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()); } } Ok(GameDirectoryDiscovery::Games(game_ids)) } async fn collect_download_readiness( game_dir: &Path, state_dir: &Path, catalog: &GameCatalog, game_ids: &[String], ) -> HashMap { // Ownership storage has its own lexical blocking boundary. Collect those // fail-closed results before entering the one filesystem/index scan batch. let mut readiness = HashMap::new(); for game_id in game_ids { if catalog.contains(game_id) { readiness.insert( game_id.clone(), download_ownership_readiness(game_dir, state_dir, game_id).await, ); } } readiness } fn scan_discovered_games( game_dir: &Path, state_dir: &Path, runtime_state: LibraryIndexRuntimeState, catalog: &GameCatalog, recovery_failed_ids: &HashSet, game_ids: &[String], download_readiness: &HashMap, ) -> eyre::Result { // The root may have changed while ownership readiness was collected. Fail // closed instead of traversing through a newly unsafe configured root. if !safe_directory_entry_sync(game_dir) { return clear_index_and_scan_empty(game_dir, state_dir, runtime_state); } let index_path = library_index_path(state_dir); let (mut index, revision_regressed) = 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; for game_id in game_ids { let readiness = download_readiness .get(game_id) .copied() .unwrap_or(DownloadOwnershipReadiness::RecoveryRequired); let update = update_index_for_game( game_dir, game_id, catalog, readiness, recovery_failed_ids.contains(game_id), &mut index, )?; changed |= update.changed; let Some(summary) = update.summary else { continue; }; seen_ids.insert(game_id.clone()); summaries.insert(game_id.clone(), summary.clone()); games.push(game_from_summary(&summary)); } let before = index.games.len(); index.games.retain(|game_id, _| seen_ids.contains(game_id)); if index.games.len() != before { changed = true; } if changed { advance_library_index_revision(&mut index)?; save_library_index(&index_path, &index)?; } Ok(LocalLibraryScan { source_game_dir: game_dir.to_path_buf(), game_db: GameDB::from(games), summaries, revision: index.revision, }) } fn rescan_game( game_dir: &Path, state_dir: &Path, runtime_state: LibraryIndexRuntimeState, catalog: &GameCatalog, game_id: &str, download_readiness: DownloadOwnershipReadiness, runtime_recovery_failed: bool, ) -> 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); let update = update_index_for_game( game_dir, game_id, catalog, download_readiness, runtime_recovery_failed, &mut index, )?; if revision_regressed || runtime_state.rewrite_required || update.changed { advance_library_index_revision(&mut index)?; save_library_index(&index_path, &index)?; } Ok(scan_from_index(&index, game_dir)) } // ============================================================================= // Game database loading // ============================================================================= /// Scans the local game directory and returns summaries plus a game database. #[cfg(test)] pub async fn scan_local_library( game_dir: impl AsRef, state_dir: impl AsRef, catalog: &GameCatalog, ) -> eyre::Result { let recovery_failed_ids = HashSet::new(); scan_local_library_with_recovery_failures(game_dir, state_dir, catalog, &recovery_failed_ids) .await } pub(crate) async fn scan_local_library_with_recovery_failures( game_dir: impl AsRef, state_dir: impl AsRef, catalog: &GameCatalog, recovery_failed_ids: &HashSet, ) -> eyre::Result { let game_path = game_dir.as_ref(); let state_path = state_dir.as_ref(); 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 game_ids = match discovery { GameDirectoryDiscovery::Missing => { log::warn!( "Local game directory {} missing; reporting empty game database", game_path.display() ); let result = scoped_blocking(|| { clear_index_and_scan_empty(game_path, state_path, runtime_state) }); return settle_library_scan(&mut states, &index_path, result); } GameDirectoryDiscovery::Unsafe => { log::warn!( "Configured game directory {} is not a safe directory; reporting empty game database", game_path.display() ); let result = scoped_blocking(|| { clear_index_and_scan_empty(game_path, state_path, runtime_state) }); return settle_library_scan(&mut states, &index_path, result); } GameDirectoryDiscovery::Games(game_ids) => game_ids, }; let download_readiness = collect_download_readiness(game_path, state_path, catalog, &game_ids).await; // Loading the prior index, inspecting game trees, and atomically replacing // the index are one non-detachable filesystem batch. let result = scoped_blocking(|| { scan_discovered_games( game_path, state_path, runtime_state, catalog, recovery_failed_ids, &game_ids, &download_readiness, ) }); settle_library_scan(&mut states, &index_path, result) } /// Rescans a single game root through the cached index and returns full library state. #[cfg(test)] pub async fn rescan_local_game( game_dir: impl AsRef, state_dir: impl AsRef, catalog: &GameCatalog, game_id: &str, ) -> eyre::Result { let recovery_failed_ids = HashSet::new(); rescan_local_game_with_recovery_failures( game_dir, state_dir, catalog, game_id, &recovery_failed_ids, ) .await } pub(crate) async fn rescan_local_game_with_recovery_failures( game_dir: impl AsRef, state_dir: impl AsRef, catalog: &GameCatalog, game_id: &str, recovery_failed_ids: &HashSet, ) -> eyre::Result { let game_path = game_dir.as_ref(); let state_path = state_dir.as_ref(); 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 should_check_ownership = catalog.contains(game_id) && scoped_blocking(|| direct_game_root_is_safe_directory_sync(&game_path.join(game_id))); let download_readiness = if should_check_ownership { download_ownership_readiness(game_path, state_path, game_id).await } else { DownloadOwnershipReadiness::RecoveryRequired }; // Keep the read-modify-write index transaction lexically owned by this // rescan after its ownership prerequisite has settled. let result = scoped_blocking(|| { rescan_game( game_path, state_path, runtime_state, catalog, game_id, download_readiness, recovery_failed_ids.contains(game_id), ) }); settle_library_scan(&mut states, &index_path, result) } #[cfg(test)] mod tests { use std::{collections::HashMap, path::Path}; use lanspread_db::db::Availability; use super::*; use crate::{ context::OperationKind, download::{seed_download_ownership_for_test, seed_pending_download_ownership_for_test}, test_support::TempDir, }; fn write_file(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("parent dir should be created"); } std::fs::write(path, bytes).expect("file should be written"); } fn test_library_index(revision: u64, id: &str, shape: u64) -> LibraryIndex { LibraryIndex { revision, games: HashMap::from([( id.to_string(), GameIndexEntry { summary: LocalGameSummary { id: id.to_string(), name: id.to_string(), size: shape, downloaded: true, installed: false, eti_version: Some("20250101".to_string()), availability: Availability::Ready, }, fingerprint: GameFingerprint { eti_files: Vec::new(), version_mtime: Some(shape), version_contents: Some("20250101".to_string()), local_dir_present: false, download_recovery_required: false, runtime_recovery_failed: false, }, }, )]), } } #[test] fn migrated_library_index_preserves_games_but_resets_revision_authority() { let original = test_library_index(u64::MAX, "game", 77); let bytes = serde_json::to_vec(&original).expect("legacy index should serialize"); let normalized = normalize_migrated_library_index(&bytes).expect("legacy index should normalize"); let migrated: LibraryIndex = serde_json::from_slice(&normalized).expect("normalized index should deserialize"); assert_eq!(migrated.revision, 0); assert_eq!(migrated.games.len(), 1); assert_eq!( migrated .games .get("game") .expect("game should be preserved") .summary .size, 77, ); } #[test] fn legacy_fingerprint_defaults_download_recovery_to_false() { let fingerprint: GameFingerprint = serde_json::from_value(serde_json::json!({ "eti_files": [], "version_mtime": 1, "version_contents": "20250101", "local_dir_present": true })) .expect("legacy fingerprint should deserialize"); assert!(!fingerprint.download_recovery_required); assert!(!fingerprint.runtime_recovery_failed); } #[test] fn save_library_index_is_atomic_on_replace() { let temp = TempDir::new("lanspread-local-games"); let index_path = library_index_path(temp.path()); let tmp_path = library_index_tmp_path(&index_path); let first = test_library_index(1, "game-a", 11); save_library_index(&index_path, &first).expect("first index write should succeed"); assert!(!tmp_path.exists()); let second = test_library_index(2, "game-b", 22); save_library_index(&index_path, &second).expect("replacement index write should succeed"); assert!(!tmp_path.exists()); let loaded = load_library_index(&index_path); assert_eq!(loaded.revision, 2); assert!(!loaded.games.contains_key("game-a")); let game_b = loaded .games .get("game-b") .expect("replacement index should be persisted"); assert_eq!(game_b.summary.size, 22); } #[test] fn load_library_index_sweeps_stale_tmp() { let temp = TempDir::new("lanspread-local-games"); let index_path = library_index_path(temp.path()); let tmp_path = library_index_tmp_path(&index_path); let canonical = test_library_index(7, "game", 77); let data = serde_json::to_vec_pretty(&canonical).expect("index should serialize"); write_file(&index_path, &data); write_file(&tmp_path, b"{ not json"); let loaded = load_library_index(&index_path); assert_eq!(loaded.revision, 7); assert!(loaded.games.contains_key("game")); assert!(!tmp_path.exists()); } #[tokio::test] async fn failed_index_save_does_not_return_or_record_a_revision() { let games = TempDir::new("lanspread-local-games-save-failure"); let state = TempDir::new("lanspread-local-games-save-failure-state"); let catalog = GameCatalog::from_ids(["game".to_string()]); write_file(&games.path().join("game/version.ini"), b"20250101"); let index_path = library_index_path(state.path()); let index_parent = index_path .parent() .expect("index should have a parent") .to_path_buf(); write_file(&index_parent, b"blocks index directory creation"); let _error = scan_local_library(games.path(), state.path(), &catalog) .await .expect_err("an index save failure must fail the scan"); std::fs::remove_file(&index_parent).expect("index blocker should be removable"); let retry = scan_local_library(games.path(), state.path(), &catalog) .await .expect("scan should succeed once durable storage is available"); assert_eq!(retry.revision, 1); assert_eq!(load_library_index(&index_path).revision, 1); } #[tokio::test] async fn scan_uses_version_ini_and_local_dir_as_independent_state() { let temp = TempDir::new("lanspread-local-games"); let state = TempDir::new("lanspread-local-games-state"); let catalog = GameCatalog::from_ids([ "ready".to_string(), "local-only".to_string(), "eti-only".to_string(), ]); write_file(&temp.path().join("ready").join("version.ini"), b"20250101"); std::fs::create_dir_all(temp.path().join("local-only").join("local")) .expect("local dir should be created"); write_file( &temp.path().join("eti-only").join("eti-only.eti"), b"archive", ); write_file( &temp.path().join("non-catalog").join("version.ini"), b"20250101", ); let scan = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("scan should succeed"); let ready = scan .summaries .get("ready") .expect("catalog game with sentinel should be indexed"); assert!(ready.downloaded); assert!(!ready.installed); assert_eq!(ready.eti_version.as_deref(), Some("20250101")); assert_eq!(ready.availability, Availability::Ready); let local_only = scan .summaries .get("local-only") .expect("local-only install should be indexed"); assert!(!local_only.downloaded); assert!(local_only.installed); assert_eq!(local_only.availability, Availability::LocalOnly); let eti_only = scan .summaries .get("eti-only") .expect("eti-only root should be retained as local state"); assert!(!eti_only.downloaded); assert!(!eti_only.installed); assert_eq!(eti_only.availability, Availability::LocalOnly); assert!(!scan.summaries.contains_key("non-catalog")); } #[cfg(unix)] #[tokio::test] async fn scan_and_readiness_never_follow_symlink_game_root() { use std::os::unix::fs::symlink; let games = TempDir::new("lanspread-local-games-symlink-root"); let outside = TempDir::new("lanspread-local-games-symlink-outside"); let state = TempDir::new("lanspread-local-games-symlink-state"); let catalog = GameCatalog::from_ids(["game".to_string()]); let game_root = games.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); write_file(&game_root.join("local/original.txt"), b"original"); let ready = scan_local_library(games.path(), state.path(), &catalog) .await .expect("ordinary game root should scan"); assert!(ready.summaries["game"].downloaded); assert!(ready.summaries["game"].installed); std::fs::remove_dir_all(&game_root).expect("ordinary game root should be removable"); write_file(&outside.path().join("version.ini"), b"20250101"); write_file(&outside.path().join("local/outside.txt"), b"outside"); write_file(&outside.path().join("canary.txt"), b"canary"); symlink(outside.path(), &game_root).expect("game-root symlink should be created"); assert!(!game_root_is_safe_directory(&game_root).await); assert!(!version_ini_is_regular_file(&game_root).await); assert!(!local_dir_is_directory(&game_root).await); assert!( !local_download_available( games.path(), state.path(), "game", &HashMap::new(), &catalog, ) .await ); assert!(scan_game_size("game", games.path()).is_err()); let quarantined = scan_local_library(games.path(), state.path(), &catalog) .await .expect("unsafe root should be omitted without traversal"); assert!(quarantined.revision > ready.revision); assert!(!quarantined.summaries.contains_key("game")); assert_eq!( std::fs::read(outside.path().join("canary.txt")) .expect("canary should remain readable"), b"canary" ); assert_eq!( std::fs::read(outside.path().join("local/outside.txt")) .expect("outside install should remain readable"), b"outside" ); } #[cfg(unix)] #[tokio::test] async fn targeted_rescan_and_readiness_never_follow_replaced_configured_root() { use std::os::unix::fs::symlink; let container = TempDir::new("lanspread-local-games-configured-root-container"); let configured_root = container.path().join("configured"); let outside = TempDir::new("lanspread-local-games-configured-root-outside"); let state = TempDir::new("lanspread-local-games-configured-root-state"); let catalog = GameCatalog::from_ids(["game".to_string()]); write_file(&configured_root.join("game/version.ini"), b"20250101"); let ready = scan_local_library(&configured_root, state.path(), &catalog) .await .expect("ordinary configured root should scan"); assert!(ready.summaries.contains_key("game")); std::fs::remove_dir_all(&configured_root) .expect("ordinary configured root should be removable"); write_file(&outside.path().join("game/version.ini"), b"20250101"); write_file(&outside.path().join("game/canary.txt"), b"outside"); symlink(outside.path(), &configured_root) .expect("configured-root symlink should be created"); let game_root = configured_root.join("game"); assert!(!game_root_is_safe_directory(&game_root).await); assert!(!version_ini_is_regular_file(&game_root).await); assert!( !local_download_available( &configured_root, state.path(), "game", &HashMap::new(), &catalog, ) .await ); assert!(scan_game_size("game", &configured_root).is_err()); let quarantined = rescan_local_game(&configured_root, state.path(), &catalog, "game") .await .expect("unsafe configured root should be removed from the index"); assert_eq!(quarantined.revision, ready.revision + 1); assert!(!quarantined.summaries.contains_key("game")); assert_eq!( std::fs::read(outside.path().join("game/canary.txt")) .expect("outside canary should remain readable"), b"outside" ); } #[cfg(unix)] #[tokio::test] async fn cached_scan_rejects_descendant_symlink() { use std::os::unix::fs::symlink; let games = TempDir::new("lanspread-local-games-descendant-link"); let outside = TempDir::new("lanspread-local-games-descendant-link-outside"); let state = TempDir::new("lanspread-local-games-descendant-link-state"); let catalog = GameCatalog::from_ids(["game".to_string()]); let game_root = games.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); write_file(&game_root.join("nested/ordinary.txt"), b"ordinary"); let initial = scan_local_library(games.path(), state.path(), &catalog) .await .expect("ordinary game tree should scan"); assert!(initial.summaries.contains_key("game")); write_file(&outside.path().join("canary.txt"), b"outside"); symlink( outside.path().join("canary.txt"), game_root.join("nested/leak.txt"), ) .expect("descendant symlink should be created"); assert!( rescan_local_game(games.path(), state.path(), &catalog, "game") .await .is_err(), "a cached fingerprint must not hide a new descendant link" ); assert!(scan_game_size("game", games.path()).is_err()); assert_eq!( load_library_index(&library_index_path(state.path())).revision, initial.revision, "a rejected tree must not advance the durable index" ); assert_eq!( std::fs::read(outside.path().join("canary.txt")) .expect("outside canary should remain readable"), b"outside" ); } #[tokio::test] async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() { let temp = TempDir::new("lanspread-local-games"); let state = TempDir::new("lanspread-local-games-state"); let catalog = GameCatalog::from_ids(["game".to_string()]); std::fs::create_dir_all(temp.path().join("game").join("local")) .expect("local install dir should be created"); let first_scan = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); let local_only = first_scan .summaries .get("game") .expect("installed-only game should be indexed"); assert!(!local_only.downloaded); assert!(local_only.installed); assert_eq!(local_only.availability, Availability::LocalOnly); write_file(&temp.path().join("game").join("version.ini"), b"20250101"); let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game") .await .expect("rescan should succeed"); let ready = rescan .summaries .get("game") .expect("ready game should remain indexed"); assert!(ready.downloaded); assert!(ready.installed); assert_eq!(ready.eti_version.as_deref(), Some("20250101")); assert_eq!(ready.availability, Availability::Ready); } #[tokio::test] async fn journal_only_recovery_transitions_invalidate_cached_ready_summary_once() { let games = TempDir::new("lanspread-local-games-journal-transition"); let state = TempDir::new("lanspread-local-games-journal-state"); let game_root = games.path().join("game"); let catalog = GameCatalog::from_ids(["game".to_string()]); write_file(&game_root.join("version.ini"), b"20250101"); write_file(&game_root.join("archive.eti"), b"archive"); std::fs::create_dir_all(game_root.join("local")) .expect("local install dir should be created"); seed_download_ownership_for_test(state.path(), games.path(), "game", &["archive.eti"]) .await; let ready_scan = scan_local_library(games.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); let initial_revision = ready_scan.revision; let ready = ready_scan .summaries .get("game") .expect("settled game should be indexed"); assert!(ready.downloaded); assert!(ready.installed); assert_eq!(ready.eti_version.as_deref(), Some("20250101")); assert_eq!(ready.availability, Availability::Ready); seed_pending_download_ownership_for_test( state.path(), games.path(), "game", &["archive.eti"], &["archive.eti"], ) .await; let quarantined_scan = rescan_local_game(games.path(), state.path(), &catalog, "game") .await .expect("quarantine rescan should succeed"); assert_eq!(quarantined_scan.revision, initial_revision + 1); let quarantined = quarantined_scan .summaries .get("game") .expect("quarantined installed game should remain indexed"); assert!(!quarantined.downloaded); assert!(quarantined.installed); assert_eq!(quarantined.eti_version, None); assert_eq!(quarantined.availability, Availability::LocalOnly); let unchanged_quarantine = rescan_local_game(games.path(), state.path(), &catalog, "game") .await .expect("repeated quarantine rescan should succeed"); assert_eq!(unchanged_quarantine.revision, quarantined_scan.revision); seed_download_ownership_for_test(state.path(), games.path(), "game", &["archive.eti"]) .await; let settled_scan = rescan_local_game(games.path(), state.path(), &catalog, "game") .await .expect("settled rescan should succeed"); assert_eq!(settled_scan.revision, quarantined_scan.revision + 1); let settled = settled_scan .summaries .get("game") .expect("settled game should remain indexed"); assert!(settled.downloaded); assert!(settled.installed); assert_eq!(settled.eti_version.as_deref(), Some("20250101")); assert_eq!(settled.availability, Availability::Ready); let unchanged_settled = rescan_local_game(games.path(), state.path(), &catalog, "game") .await .expect("repeated settled rescan should succeed"); assert_eq!(unchanged_settled.revision, settled_scan.revision); } #[tokio::test] async fn runtime_recovery_failure_hides_download_and_install_until_retry() { let games = TempDir::new("lanspread-local-games-runtime-recovery"); let state = TempDir::new("lanspread-local-games-runtime-recovery-state"); let game_root = games.path().join("game"); let catalog = GameCatalog::from_ids(["game".to_string()]); write_file(&game_root.join("version.ini"), b"20250101"); write_file(&game_root.join("archive.eti"), b"archive"); write_file(&game_root.join("local/payload.txt"), b"installed"); let ready = scan_local_library(games.path(), state.path(), &catalog) .await .expect("ready library should scan"); let ready_revision = ready.revision; assert!(ready.summaries["game"].downloaded); assert!(ready.summaries["game"].installed); let failed_ids = HashSet::from(["game".to_string()]); let failed = scan_local_library_with_recovery_failures( games.path(), state.path(), &catalog, &failed_ids, ) .await .expect("failed recovery projection should scan"); assert!(failed.revision > ready_revision); assert!(!failed.summaries["game"].downloaded); assert!(!failed.summaries["game"].installed); assert_eq!( failed.summaries["game"].availability, Availability::LocalOnly ); let retried = scan_local_library_with_recovery_failures( games.path(), state.path(), &catalog, &HashSet::new(), ) .await .expect("successful retry projection should scan"); assert!(retried.revision > failed.revision); assert!(retried.summaries["game"].downloaded); assert!(retried.summaries["game"].installed); } #[tokio::test] async fn missing_game_directory_produces_monotonic_empty_revision() { let games = TempDir::new("lanspread-local-games-missing-root"); let state = TempDir::new("lanspread-local-games-missing-root-state"); let game_root = games.path().join("game"); let catalog = GameCatalog::from_ids(["game".to_string()]); write_file(&game_root.join("version.ini"), b"20250101"); write_file(&game_root.join("archive.eti"), b"archive"); let ready_scan = scan_local_library(games.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); assert!(ready_scan.summaries.contains_key("game")); std::fs::remove_dir_all(games.path()).expect("games directory should be removable"); let empty = scan_local_library(games.path(), state.path(), &catalog) .await .expect("missing root should produce an empty scan"); assert!(empty.summaries.is_empty()); assert!(empty.game_db.games.is_empty()); assert!(empty.revision > ready_scan.revision); } #[tokio::test] async fn corrupt_index_rebuilds_above_in_process_revision_floor() { let games = TempDir::new("lanspread-local-games-corrupt-index"); let state = TempDir::new("lanspread-local-games-corrupt-index-state"); let catalog = GameCatalog::from_ids(["game".to_string()]); let game_root = games.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); let first = scan_local_library(games.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); assert_eq!(first.revision, 1); write_file(&game_root.join("archive.eti"), b"archive"); let second = rescan_local_game(games.path(), state.path(), &catalog, "game") .await .expect("changed game should rescan"); assert_eq!(second.revision, 2); let index_path = library_index_path(state.path()); write_file(&index_path, b"{ corrupt index"); let rebuilt = scan_local_library(games.path(), state.path(), &catalog) .await .expect("corrupt index should rebuild above the live revision floor"); assert_eq!(rebuilt.revision, second.revision + 1); assert!(rebuilt.summaries.contains_key("game")); assert_eq!(load_library_index(&index_path).revision, rebuilt.revision); } #[tokio::test] async fn concurrent_rescans_preserve_both_index_updates() { let temp = TempDir::new("lanspread-local-games-concurrent"); let state = TempDir::new("lanspread-local-games-state"); let catalog = GameCatalog::from_ids(["game-a".to_string(), "game-b".to_string()]); write_file(&temp.path().join("game-a").join("version.ini"), b"20250101"); write_file(&temp.path().join("game-b").join("version.ini"), b"20250101"); let initial = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); assert_eq!(initial.revision, 1); write_file(&temp.path().join("game-a").join("game-a.eti"), b"archive-a"); write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b"); let (scan_a, scan_b) = tokio::join!( rescan_local_game(temp.path(), state.path(), &catalog, "game-a"), rescan_local_game(temp.path(), state.path(), &catalog, "game-b") ); 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())); assert_eq!(index.revision, 3); let game_a = index .games .get("game-a") .expect("game-a update should remain in index"); let game_b = index .games .get("game-b") .expect("game-b update should remain in index"); assert!( game_a.summary.size > 8, "game-a rescan should persist the new archive" ); assert!( game_b.summary.size > 8, "game-b rescan should persist the new archive" ); } #[tokio::test] async fn local_download_available_gates_on_catalog_operation_and_sentinel() { let temp = TempDir::new("lanspread-local-games"); let state = TempDir::new("lanspread-local-games-state"); let game_root = temp.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); let catalog = GameCatalog::from_ids(["game".to_string()]); let no_operations = HashMap::new(); assert!( local_download_available(temp.path(), state.path(), "game", &no_operations, &catalog) .await ); let active_operations = HashMap::from([("game".to_string(), OperationKind::Downloading)]); assert!( !local_download_available( temp.path(), state.path(), "game", &active_operations, &catalog ) .await ); assert!( !local_download_available( temp.path(), state.path(), "game", &no_operations, &GameCatalog::empty() ) .await ); assert!( !local_download_available( temp.path(), state.path(), "missing", &no_operations, &catalog ) .await ); } #[tokio::test] async fn local_download_available_quarantines_only_matching_pending_ownership() { let games = TempDir::new("lanspread-local-games-readiness"); let foreign_games = TempDir::new("lanspread-local-games-foreign-readiness"); let state = TempDir::new("lanspread-local-games-readiness-state"); let game_root = games.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); let catalog = GameCatalog::from_ids(["game".to_string()]); let no_operations = HashMap::new(); seed_pending_download_ownership_for_test( state.path(), games.path(), "game", &["archive.eti"], &["archive.eti"], ) .await; assert!( !local_download_available(games.path(), state.path(), "game", &no_operations, &catalog) .await ); seed_download_ownership_for_test(state.path(), games.path(), "game", &["archive.eti"]) .await; assert!( local_download_available(games.path(), state.path(), "game", &no_operations, &catalog) .await ); seed_pending_download_ownership_for_test( state.path(), foreign_games.path(), "game", &["archive.eti"], &["archive.eti"], ) .await; assert!( local_download_available(games.path(), state.path(), "game", &no_operations, &catalog) .await ); } #[tokio::test] async fn local_download_matches_catalog_requires_expected_version() { let temp = TempDir::new("lanspread-local-games"); let state = TempDir::new("lanspread-local-games-state"); let game_root = temp.path().join("game"); write_file(&game_root.join("version.ini"), b"20260101"); let mut catalog = GameCatalog::empty(); catalog.insert("game".to_string(), Some("20250101".to_string())); let no_operations = HashMap::new(); assert!( !local_download_matches_catalog( temp.path(), state.path(), "game", &no_operations, &catalog ) .await ); catalog.insert("game".to_string(), Some("20260101".to_string())); assert!( local_download_matches_catalog( temp.path(), state.path(), "game", &no_operations, &catalog ) .await ); } }