Files
lanspread/crates/lanspread-peer/src/local_games.rs
T
ddidderr 7de373afb6 fix(peer): sanitize legacy library index migration
The selected game root could supply an unbounded legacy library index whose
revision was copied verbatim into app-owned state. A revision of `u64::MAX`
made every later checked revision advance fail even after the original root was
removed.

Read only a bounded regular, non-link file whose identity stays stable across
the read. Deserialize it before publication, preserve its cached game data, and
reset its stale revision authority to zero. Existing app-owned state still wins
without reading the legacy source, and invalid input remains in place for
recovery.

Test Plan:
- `just test` -- passed outside the sandbox; 494 peer tests and all workspace
  targets passed.
- The initial sandboxed `just test` reached an unrelated Unix-socket permission
  denial, then passed unchanged with the required socket permission.
- `git diff --cached --check` -- passed.
2026-09-12 12:19:50 +02:00

1748 lines
59 KiB
Rust

//! 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<String, OperationKind>,
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<String, OperationKind>,
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<Mutex<HashMap<PathBuf, LibraryIndexRuntimeState>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LibraryIndex {
revision: u64,
games: HashMap<String, GameIndexEntry>,
}
/// 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<Vec<u8>> {
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<EtiFingerprint>,
version_mtime: Option<u64>,
#[serde(default)]
version_contents: Option<String>,
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<u64>,
}
#[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<String, LocalGameSummary>,
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<Vec<EtiFingerprint>> {
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<GameFingerprint> {
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<u64, PeerError> {
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<LocalGameSummary, PeerError> {
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<LocalGameSummary>,
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<IndexUpdate> {
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<LocalLibraryScan> {
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::<HashMap<_, _>>();
let games = index
.games
.values()
.map(|entry| game_from_summary(&entry.summary))
.collect::<Vec<_>>();
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<PathBuf, LibraryIndexRuntimeState>,
index_path: &Path,
result: eyre::Result<LocalLibraryScan>,
) -> eyre::Result<LocalLibraryScan> {
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<String>),
}
fn discover_game_ids(game_dir: &Path) -> eyre::Result<GameDirectoryDiscovery> {
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<String, DownloadOwnershipReadiness> {
// 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<String>,
game_ids: &[String],
download_readiness: &HashMap<String, DownloadOwnershipReadiness>,
) -> eyre::Result<LocalLibraryScan> {
// 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<LocalLibraryScan> {
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<Path>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
) -> eyre::Result<LocalLibraryScan> {
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<Path>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
recovery_failed_ids: &HashSet<String>,
) -> eyre::Result<LocalLibraryScan> {
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<Path>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
game_id: &str,
) -> eyre::Result<LocalLibraryScan> {
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<Path>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
game_id: &str,
recovery_failed_ids: &HashSet<String>,
) -> eyre::Result<LocalLibraryScan> {
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
);
}
}