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.
1748 lines
59 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|