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:
128 files changed
+51759
-10784
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user