feat(peer)!: cut over to authenticated catalog sharing

Replace address-only trust and pushed peer state with installation identities,
SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned
protocol-8 pulls. The runtime now owns each network generation and all admitted
work through shutdown.

Add exact bundled content identities, reproducible manifest publishing,
capability-confined downloads, streaming BLAKE3 verification, quarantine and
retry, and crash-recoverable download and install transactions. Ship generated
fixture catalogs and fail closed when production manifests are absent.

The Tauri backend exposes durable sharing policy, redacted identity state, and
attempt-keyed transfer snapshots. Frontend consumption follows in the next
commit. Repository-wide test certificates and protocol-7 paths are removed.

BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts;
protocol-7 frames and shared-certificate identities are no longer accepted.

Test Plan:
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `just build` -- passed with fixture catalogs on the completed stack
- `just catalog-check-production` -- failed closed because the external
  production manifest corpus is absent
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-08-10 13:59:18 +02:00
1 parent 36c4785775
commit 60fd7ba0c2
128 files changed
+51759 -10784

No files matched your search

+1010 -273
View File
@@ -2,20 +2,20 @@
use std::{
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
io::ErrorKind,
path::{Component, Path, PathBuf},
fs::Metadata,
io::{ErrorKind, Write as _},
path::{Path, PathBuf},
sync::LazyLock,
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription};
use lanspread_proto::{Availability, GameSummary};
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB};
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, sync::Mutex};
use tokio::sync::Mutex;
use crate::{
context::OperationKind,
download::{DownloadOwnershipReadiness, download_ownership_readiness},
error::PeerError,
game_paths::{
LEGACY_LIBRARY_INDEX_DIR,
@@ -24,6 +24,8 @@ use crate::{
is_download_protected_root_name,
is_ignored_games_root_name,
},
library::LocalGameSummary,
scoped_blocking::scoped_blocking,
};
// =============================================================================
@@ -32,23 +34,72 @@ use crate::{
/// 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);
tokio::fs::metadata(&local_dir)
.await
.is_ok_and(|metadata| metadata.is_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);
tokio::fs::metadata(&version_path)
.await
.is_ok_and(|metadata| metadata.is_file())
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,
@@ -63,6 +114,22 @@ pub async fn local_download_available(
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
}
@@ -70,11 +137,12 @@ pub async fn local_download_available(
/// 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, game_id, active_operations, catalog).await {
if !local_download_available(game_dir, state_dir, game_id, active_operations, catalog).await {
return false;
}
@@ -83,7 +151,8 @@ pub async fn local_download_matches_catalog(
};
let game_path = game_dir.join(game_id);
match lanspread_db::db::read_version_from_ini(&game_path) {
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!(
@@ -107,7 +176,14 @@ pub async fn local_download_matches_catalog(
const LIBRARY_INDEX_FILE: &str = "library_index.json";
static LIBRARY_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[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 {
@@ -117,7 +193,7 @@ struct LibraryIndex {
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GameIndexEntry {
summary: GameSummary,
summary: LocalGameSummary,
fingerprint: GameFingerprint,
}
@@ -128,6 +204,10 @@ struct GameFingerprint {
#[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)]
@@ -139,8 +219,14 @@ struct EtiFingerprint {
#[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, GameSummary>,
pub summaries: HashMap<String, LocalGameSummary>,
pub revision: u64,
}
@@ -164,9 +250,9 @@ fn library_index_tmp_path(path: &Path) -> PathBuf {
path.with_file_name(tmp_name)
}
async fn sweep_stale_library_index_tmp(path: &Path) {
fn sweep_stale_library_index_tmp(path: &Path) {
let tmp_path = library_index_tmp_path(path);
match tokio::fs::remove_file(&tmp_path).await {
match std::fs::remove_file(&tmp_path) {
Ok(()) => log::debug!(
"Removed stale library index temp file {}",
tmp_path.display()
@@ -179,10 +265,10 @@ async fn sweep_stale_library_index_tmp(path: &Path) {
}
}
async fn load_library_index(path: &Path) -> LibraryIndex {
sweep_stale_library_index_tmp(path).await;
fn load_library_index(path: &Path) -> LibraryIndex {
sweep_stale_library_index_tmp(path);
let data = match tokio::fs::read_to_string(path).await {
let data = match std::fs::read_to_string(path) {
Ok(data) => data,
Err(err) => {
if err.kind() != ErrorKind::NotFound {
@@ -207,19 +293,37 @@ async fn load_library_index(path: &Path) -> LibraryIndex {
}
}
async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> {
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() {
tokio::fs::create_dir_all(parent).await?;
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 = tokio::fs::File::create(&tmp_path).await?;
file.write_all(&data).await?;
file.sync_all().await?;
let mut file = std::fs::File::create(&tmp_path)?;
file.write_all(&data)?;
file.sync_all()?;
drop(file);
tokio::fs::rename(&tmp_path, path).await?;
std::fs::rename(&tmp_path, path)?;
sync_parent_dir(path)?;
Ok(())
}
@@ -249,17 +353,24 @@ fn is_root_eti_name(name: &str) -> bool {
.is_some_and(|extension| extension == "eti")
}
async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerprint>> {
let mut entries = match tokio::fs::read_dir(game_path).await {
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();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
if !file_type.is_file() {
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;
}
@@ -270,7 +381,6 @@ async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerpr
continue;
}
let metadata = entry.metadata().await?;
eti_files.push(EtiFingerprint {
name,
size: metadata.len(),
@@ -282,13 +392,18 @@ async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerpr
Ok(eti_files)
}
async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint> {
let eti_files = root_eti_fingerprints(game_path).await?;
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 tokio::fs::metadata(&version_path).await {
Ok(metadata) if metadata.is_file() => {
let contents = match tokio::fs::read_to_string(&version_path).await {
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!(
@@ -303,13 +418,15 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
Err(_) | Ok(_) => (None, None),
};
let local_dir_present = local_dir_is_directory(game_path).await;
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,
})
}
@@ -324,112 +441,89 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
.is_some_and(is_download_protected_root_name)
}
fn canonical_protocol_path(path: &Path) -> eyre::Result<String> {
let mut components = Vec::new();
for component in path.components() {
let Component::Normal(component) = component else {
eyre::bail!("local game path is not canonical: {}", path.display());
};
let component = component
.to_str()
.ok_or_else(|| eyre::eyre!("local game path is not valid UTF-8: {}", path.display()))?;
if component.contains('\\') {
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 path cannot be represented portably: {}",
path.display()
"local game tree contains a link or reparse point: {}",
entry.path().display()
);
}
components.push(component);
}
if components.is_empty() {
eyre::bail!("local game path cannot be empty");
}
Ok(components.join("/"))
Ok(())
}
async fn scan_game_descriptions(
game_id: &str,
game_dir: &Path,
) -> Result<Vec<GameFileDescription>, PeerError> {
let base_dir = game_dir;
let game_path = base_dir.join(game_id);
fn scan_game_size(game_id: &str, game_dir: &Path) -> Result<u64, PeerError> {
let game_path = game_dir.join(game_id);
if !game_path.exists() {
if !direct_game_root_is_safe_directory_sync(&game_path) {
return Err(PeerError::Other(eyre::eyre!(
"Game directory does not exist: {}",
"Game directory is missing or unsafe: {}",
game_path.display()
)));
}
let mut file_descriptions = Vec::new();
for entry in walkdir::WalkDir::new(&game_path)
.into_iter()
.filter_entry(|entry| !should_skip_root_entry(entry))
.filter_map(std::result::Result::ok)
{
let relative_path = match entry.path().strip_prefix(base_dir) {
Ok(path) => canonical_protocol_path(path).map_err(PeerError::Other)?,
Err(e) => {
log::error!(
"Failed to get relative path for {}: {}",
entry.path().display(),
e
);
continue;
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,
});
}
};
let is_dir = entry.file_type().is_dir();
let size = if is_dir {
0
} else {
match tokio::fs::metadata(entry.path()).await {
Ok(metadata) => metadata.len(),
Err(e) => {
log::error!("Failed to read metadata for {relative_path}: {e}");
return Err(PeerError::FileSizeDetermination {
path: relative_path.clone(),
source: e,
});
}
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()
)));
}
let file_desc = GameFileDescription {
game_id: game_id.to_string(),
relative_path,
is_dir,
size,
};
file_descriptions.push(file_desc);
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(file_descriptions)
Ok(total_size)
}
fn manifest_hash(file_descriptions: &[GameFileDescription]) -> u64 {
let mut entries: Vec<_> = file_descriptions
.iter()
.filter(|desc| !desc.is_dir)
.map(|desc| (&desc.relative_path, desc.size, desc.is_dir))
.collect();
entries.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(&b.1)));
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for (path, size, is_dir) in entries {
path.hash(&mut hasher);
size.hash(&mut hasher);
is_dir.hash(&mut hasher);
}
hasher.finish()
}
async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummary, PeerError> {
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 = version_ini_is_regular_file(&game_path).await;
let installed = local_dir_is_directory(&game_path).await;
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) {
@@ -443,32 +537,25 @@ async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummar
None
};
let file_descriptions = scan_game_descriptions(game_id, game_dir).await?;
let total_size = file_descriptions
.iter()
.filter(|desc| !desc.is_dir)
.map(|desc| desc.size)
.sum();
let manifest_hash = manifest_hash(&file_descriptions);
let total_size = scan_game_size(game_id, game_dir)?;
let availability = if downloaded {
Availability::Ready
} else {
Availability::LocalOnly
};
Ok(GameSummary {
Ok(LocalGameSummary {
id: game_id.to_string(),
name: game_id.to_string(),
size: total_size,
downloaded,
installed,
eti_version,
manifest_hash,
availability,
})
}
pub(crate) fn game_from_summary(summary: &GameSummary) -> Game {
pub(crate) fn game_from_summary(summary: &LocalGameSummary) -> Game {
Game {
id: summary.id.clone(),
name: summary.name.clone(),
@@ -489,14 +576,16 @@ pub(crate) fn game_from_summary(summary: &GameSummary) -> Game {
}
struct IndexUpdate {
summary: Option<GameSummary>,
summary: Option<LocalGameSummary>,
changed: bool,
}
async fn update_index_for_game(
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) {
@@ -507,7 +596,21 @@ async fn update_index_for_game(
}
let game_path = game_root.join(game_id);
let fingerprint = fingerprint_game_dir(&game_path).await?;
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
@@ -524,18 +627,15 @@ async fn update_index_for_game(
Some(entry) if entry.fingerprint == fingerprint => entry.summary.clone(),
_ => {
changed = true;
build_game_summary(game_root, game_id).await?
build_game_summary(
game_root,
game_id,
download_recovery_required,
runtime_recovery_failed,
)?
}
};
if index
.games
.get(game_id)
.is_some_and(|entry| entry.summary.manifest_hash != summary.manifest_hash)
{
changed = true;
}
index.games.insert(
game_id.to_string(),
GameIndexEntry {
@@ -550,15 +650,32 @@ async fn update_index_for_game(
})
}
fn empty_scan() -> LocalLibraryScan {
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: 0,
revision,
}
}
fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
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()
@@ -571,78 +688,138 @@ fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan {
.collect::<Vec<_>>();
LocalLibraryScan {
source_game_dir: game_dir.to_path_buf(),
game_db: GameDB::from(games),
summaries,
revision: index.revision,
}
}
// =============================================================================
// Game database loading
// =============================================================================
/// Scans the local game directory and returns summaries plus a game database.
pub async fn scan_local_library(
game_dir: impl AsRef<Path>,
state_dir: impl AsRef<Path>,
catalog: &GameCatalog,
fn settle_library_scan(
states: &mut HashMap<PathBuf, LibraryIndexRuntimeState>,
index_path: &Path,
result: eyre::Result<LocalLibraryScan>,
) -> eyre::Result<LocalLibraryScan> {
let game_path = game_dir.as_ref();
let state_path = state_dir.as_ref();
let metadata = match tokio::fs::metadata(game_path).await {
Ok(metadata) => metadata,
Err(err) => {
if err.kind() == ErrorKind::NotFound {
log::warn!(
"Local game directory {} missing; reporting empty game database",
game_path.display()
);
return Ok(empty_scan());
}
return Err(err.into());
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() {
log::warn!(
"Configured game directory {} is not a directory; reporting empty game database",
game_path.display()
);
return Ok(empty_scan());
if !metadata.is_dir() || is_link_or_reparse(&metadata) {
return Ok(GameDirectoryDiscovery::Unsafe);
}
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
let index_path = library_index_path(state_path);
let mut index = load_library_index(&index_path).await;
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 = false;
let mut changed = revision_regressed || runtime_state.rewrite_required;
let mut entries = tokio::fs::read_dir(game_path).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if is_ignored_games_root_name(game_id) {
continue;
}
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
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.to_string());
summaries.insert(game_id.to_string(), summary.clone());
seen_ids.insert(game_id.clone());
summaries.insert(game_id.clone(), summary.clone());
games.push(game_from_summary(&summary));
}
@@ -653,69 +830,183 @@ pub async fn scan_local_library(
}
if changed {
index.revision = index.revision.saturating_add(1);
if let Err(err) = save_library_index(&index_path, &index).await {
log::warn!(
"Failed to persist library index {}: {err}",
index_path.display()
);
}
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 game_path = game_dir.as_ref();
let state_path = state_dir.as_ref();
let _index_guard = LIBRARY_INDEX_LOCK.lock().await;
let index_path = library_index_path(state_path);
let mut index = load_library_index(&index_path).await;
let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?;
if update.changed {
index.revision = index.revision.saturating_add(1);
if let Err(err) = save_library_index(&index_path, &index).await {
log::warn!(
"Failed to persist library index {}: {err}",
index_path.display()
);
}
}
Ok(scan_from_index(&index))
let recovery_failed_ids = HashSet::new();
rescan_local_game_with_recovery_failures(
game_dir,
state_dir,
catalog,
game_id,
&recovery_failed_ids,
)
.await
}
// =============================================================================
// Game file descriptions
// =============================================================================
/// Gets file descriptions for a game from the local filesystem.
pub async fn get_game_file_descriptions(
game_id: &str,
pub(crate) async fn rescan_local_game_with_recovery_failures(
game_dir: impl AsRef<Path>,
) -> Result<Vec<GameFileDescription>, PeerError> {
scan_game_descriptions(game_id, game_dir.as_ref()).await
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_proto::Availability;
use lanspread_db::db::Availability;
use super::*;
use crate::{context::OperationKind, test_support::TempDir};
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() {
@@ -724,72 +1015,74 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written");
}
#[test]
fn protocol_paths_always_use_forward_slashes() {
let native = PathBuf::from("game").join("nested").join("archive.eti");
assert_eq!(
canonical_protocol_path(&native).expect("native path should convert"),
"game/nested/archive.eti"
);
}
fn test_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex {
fn test_library_index(revision: u64, id: &str, shape: u64) -> LibraryIndex {
LibraryIndex {
revision,
games: HashMap::from([(
id.to_string(),
GameIndexEntry {
summary: GameSummary {
summary: LocalGameSummary {
id: id.to_string(),
name: id.to_string(),
size: manifest_hash,
size: shape,
downloaded: true,
installed: false,
eti_version: Some("20250101".to_string()),
manifest_hash,
availability: Availability::Ready,
},
fingerprint: GameFingerprint {
eti_files: Vec::new(),
version_mtime: Some(manifest_hash),
version_mtime: Some(shape),
version_contents: Some("20250101".to_string()),
local_dir_present: false,
download_recovery_required: false,
runtime_recovery_failed: false,
},
},
)]),
}
}
#[tokio::test]
async fn save_library_index_is_atomic_on_replace() {
#[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)
.await
.expect("first index write should succeed");
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)
.await
.expect("replacement index write should succeed");
save_library_index(&index_path, &second).expect("replacement index write should succeed");
assert!(!tmp_path.exists());
let loaded = load_library_index(&index_path).await;
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.manifest_hash, 22);
assert_eq!(game_b.summary.size, 22);
}
#[tokio::test]
async fn load_library_index_sweeps_stale_tmp() {
#[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);
@@ -799,13 +1092,40 @@ mod tests {
write_file(&index_path, &data);
write_file(&tmp_path, b"{ not json");
let loaded = load_library_index(&index_path).await;
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");
@@ -860,6 +1180,158 @@ mod tests {
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");
@@ -894,6 +1366,183 @@ mod tests {
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");
@@ -917,7 +1566,7 @@ mod tests {
scan_a.expect("game-a rescan should succeed");
scan_b.expect("game-b rescan should succeed");
let index = load_library_index(&library_index_path(state.path())).await;
let index = load_library_index(&library_index_path(state.path()));
assert_eq!(index.revision, 3);
let game_a = index
.games
@@ -940,26 +1589,100 @@ mod tests {
#[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(), "game", &no_operations, &catalog).await);
let active_operations = HashMap::from([("game".to_string(), OperationKind::Downloading)]);
assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await);
assert!(
!local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty())
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
);
assert!(!local_download_available(temp.path(), "missing", &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");
@@ -968,12 +1691,26 @@ mod tests {
let no_operations = HashMap::new();
assert!(
!local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await
!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(), "game", &no_operations, &catalog).await
local_download_matches_catalog(
temp.path(),
state.path(),
"game",
&no_operations,
&catalog
)
.await
);
}
}