Files
lanspread/crates/lanspread-peer/src/install/transaction.rs
T
ddidderr 18f21bdf30 fix(launch): stamp first-use settings on every launch path
First-use launch settings moved out of install/update transactions, but three
edge cases could still leave archive stub values in place while the marker said
the settings pass had already happened.

Start Server now runs the same stamping preflight as Play before launching
server_start.cmd. That covers games whose server scripts read account_name.txt,
language.txt, or SmartSteamEmu.ini before the user ever presses Play.

Install and update now reset the per-game marker before committing
InstallIntent::None. Recovery also clears the marker for install/update states
where the new local tree has already landed, so a crash after promotion cannot
publish a clean intent while preserving a stale marker. Rollback recovery keeps
the marker, because the old local tree remains the active install.

SmartSteamEmu.ini stamping now searches every matching file until one contains a
PersonaName line. This keeps a decoy or incomplete INI from permanently blocking
the real one while still preserving early-exit behavior for account_name.txt and
language.txt, which only need the first matching file.

Test Plan:
- just fmt
- just test
- just clippy
- git diff --check

Refs: local review findings
2026-05-29 06:52:00 +02:00

1106 lines
36 KiB
Rust

use std::{
collections::HashSet,
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
};
use eyre::WrapErr;
use super::{
intent::{InstallIntent, InstallIntentState, read_intent, write_intent},
unpack::Unpacker,
};
use crate::{local_games::version_ini_is_regular_file, state_paths::launch_settings_applied_path};
const LOCAL_DIR: &str = "local";
const INSTALLING_DIR: &str = ".local.installing";
const BACKUP_DIR: &str = ".local.backup";
const OWNED_MARKER: &str = ".lanspread_owned";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState {
Present,
Missing,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct InstallFsState {
local: FsEntryState,
installing: FsEntryState,
backup: FsEntryState,
}
pub async fn install(
game_root: &Path,
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()),
)
.await?;
let result = install_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
reset_launch_settings_marker(state_dir, id).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
if let Err(cleanup_err) = remove_dir_all_if_exists(&installing_dir(game_root)).await {
log::warn!(
"Failed to clean install staging {}: {cleanup_err}",
installing_dir(game_root).display()
);
}
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn update(
game_root: &Path,
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()),
)
.await?;
let result = update_inner(game_root, id, unpacker).await;
match result {
Ok(()) => {
reset_launch_settings_marker(state_dir, id).await?;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
if let Err(err) = remove_dir_all_if_exists(&backup_dir(game_root)).await {
log::warn!(
"Failed to clean install backup {}: {err}",
backup_dir(game_root).display()
);
}
Ok(())
}
Err(err) => {
let rollback = rollback_update(game_root).await;
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
Err(err)
}
}
}
pub async fn uninstall(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
state_dir,
id,
&InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()),
)
.await?;
let result = uninstall_inner(game_root).await;
match result {
Ok(()) => {
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Ok(())
}
Err(err) => {
let rollback = restore_backup(game_root).await;
if let Err(rollback_err) = rollback {
return Err(err.wrap_err(format!("rollback also failed: {rollback_err}")));
}
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
Err(err)
}
}
}
pub async fn recover_on_startup(
game_dir: &Path,
state_dir: &Path,
active_ids: &HashSet<String>,
) -> eyre::Result<()> {
recover_download_transients(game_dir).await?;
let mut entries = match tokio::fs::read_dir(game_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err.into()),
};
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_dir() {
continue;
}
let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if id == ".lanspread" {
continue;
}
if active_ids.contains(&id) {
log::debug!("Skipping recovery for active game root {id}");
continue;
}
recover_game_root(&entry.path(), state_dir, &id).await?;
}
Ok(())
}
pub async fn recover_game_root(game_root: &Path, state_dir: &Path, id: &str) -> eyre::Result<()> {
recover_download_transients(game_root).await?;
let intent = read_intent(state_dir, id).await;
let fs = inspect_install_fs(game_root).await;
match intent.state {
InstallIntentState::None => recover_none_intent(game_root).await?,
InstallIntentState::Installing => {
recover_installing(game_root, state_dir, id, intent, fs).await?;
}
InstallIntentState::Updating => {
recover_updating(game_root, state_dir, id, intent, fs).await?;
}
InstallIntentState::Uninstalling => {
recover_uninstalling(game_root, state_dir, id, intent, fs).await?;
}
}
Ok(())
}
async fn install_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
if path_is_dir(&local).await {
eyre::bail!("game {id} is already installed");
}
let staging = installing_dir(game_root);
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
Ok(())
}
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
let staging = installing_dir(game_root);
if !path_is_dir(&local).await {
eyre::bail!("game {id} is not installed");
}
prepare_backup_slot(&backup).await?;
tokio::fs::rename(&local, &backup)
.await
.wrap_err_with(|| format!("failed to move existing install for {id} to backup"))?;
drop_owned_marker(&backup).await?;
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
Ok(())
}
async fn uninstall_inner(game_root: &Path) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
if !path_is_dir(&local).await {
return Ok(());
}
prepare_backup_slot(&backup).await?;
tokio::fs::rename(&local, &backup).await?;
drop_owned_marker(&backup).await?;
tokio::fs::remove_dir_all(&backup).await?;
Ok(())
}
async fn unpack_archives(
game_root: &Path,
staging: &Path,
unpacker: Arc<dyn Unpacker>,
) -> eyre::Result<()> {
let archives = root_eti_archives(game_root).await?;
if archives.is_empty() {
eyre::bail!("no .eti archives found in {}", game_root.display());
}
for archive in archives {
unpacker.unpack(&archive, staging).await?;
}
Ok(())
}
async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
let mut entries = tokio::fs::read_dir(game_root).await?;
let mut archives = Vec::new();
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "eti") {
archives.push(path);
}
}
archives.sort();
Ok(archives)
}
/// Drop the per-game launch-settings marker before committing install/update
/// success, so recovery can retry the reset before publishing a clean intent.
async fn reset_launch_settings_marker(state_dir: &Path, id: &str) -> eyre::Result<()> {
let marker = launch_settings_applied_path(state_dir, id);
remove_file_if_exists(&marker).await.wrap_err_with(|| {
format!(
"failed to reset launch-settings marker {}",
marker.display()
)
})
}
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
sweep_owned_orphan(&installing_dir(game_root)).await?;
sweep_owned_orphan(&backup_dir(game_root)).await?;
Ok(())
}
async fn recover_installing(
game_root: &Path,
state_dir: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
let commit_landed = fs.local == FsEntryState::Present;
if let InstallFsState {
installing: FsEntryState::Present,
..
} = fs
{
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
}
if commit_landed {
reset_launch_settings_marker(state_dir, id).await?;
}
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_updating(
game_root: &Path,
state_dir: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
if matches!(
fs,
InstallFsState {
local: FsEntryState::Present,
backup: FsEntryState::Present,
..
}
) {
reset_launch_settings_marker(state_dir, id).await?;
}
match fs {
InstallFsState {
local: FsEntryState::Missing,
installing: FsEntryState::Present,
backup: FsEntryState::Present,
} => {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
restore_backup(game_root).await?;
}
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Present,
backup: FsEntryState::Present,
} => {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
remove_dir_all_if_exists(&backup_dir(game_root)).await?;
}
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Missing,
backup: FsEntryState::Present,
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
_ => {}
}
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_uninstalling(
game_root: &Path,
state_dir: &Path,
id: &str,
intent: InstallIntent,
fs: InstallFsState,
) -> eyre::Result<()> {
match fs {
InstallFsState {
local: FsEntryState::Missing,
installing: FsEntryState::Missing,
backup: FsEntryState::Present,
} => remove_dir_all_if_exists(&backup_dir(game_root)).await?,
InstallFsState {
local: FsEntryState::Present,
installing: FsEntryState::Missing,
backup: FsEntryState::Missing,
} => uninstall_inner(game_root).await?,
_ => {}
}
write_intent(state_dir, id, &InstallIntent::none(id, intent.eti_version)).await
}
async fn recover_download_transients(root: &Path) -> eyre::Result<()> {
remove_file_if_exists(&root.join(VERSION_TMP_FILE)).await?;
remove_file_if_exists(&root.join(VERSION_DISCARDED_FILE)).await?;
Ok(())
}
async fn inspect_install_fs(game_root: &Path) -> InstallFsState {
InstallFsState {
local: path_is_dir(&local_dir(game_root)).await.into(),
installing: path_is_dir(&installing_dir(game_root)).await.into(),
backup: path_is_dir(&backup_dir(game_root)).await.into(),
}
}
async fn read_downloaded_version(game_root: &Path) -> Option<String> {
if !version_ini_is_regular_file(game_root).await {
return None;
}
match lanspread_db::db::read_version_from_ini(game_root) {
Ok(version) => version,
Err(err) => {
log::warn!(
"Failed to read version.ini in {}: {err}",
game_root.display()
);
None
}
}
}
async fn prepare_owned_empty_dir(path: &Path) -> eyre::Result<()> {
if path.exists() {
if owned_marker(path).is_file() {
tokio::fs::remove_dir_all(path).await?;
} else {
eyre::bail!("refusing to reuse markerless directory {}", path.display());
}
}
tokio::fs::create_dir_all(path).await?;
drop_owned_marker(path).await
}
async fn prepare_backup_slot(path: &Path) -> eyre::Result<()> {
if !path.exists() {
return Ok(());
}
if owned_marker(path).is_file() {
tokio::fs::remove_dir_all(path).await?;
return Ok(());
}
eyre::bail!("refusing to replace markerless backup {}", path.display());
}
async fn drop_owned_marker(path: &Path) -> eyre::Result<()> {
tokio::fs::write(owned_marker(path), []).await?;
Ok(())
}
async fn sweep_owned_orphan(path: &Path) -> eyre::Result<()> {
if !path.exists() {
return Ok(());
}
if owned_marker(path).is_file() {
remove_dir_all_if_exists(path).await?;
} else {
log::warn!(
"Leaving markerless reserved directory untouched: {}",
path.display()
);
}
Ok(())
}
async fn rollback_update(game_root: &Path) -> eyre::Result<()> {
remove_dir_all_if_exists(&installing_dir(game_root)).await?;
restore_backup(game_root).await
}
async fn restore_backup(game_root: &Path) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
if !path_is_dir(&backup).await {
return Ok(());
}
remove_dir_all_if_exists(&local).await?;
tokio::fs::rename(&backup, &local).await?;
Ok(())
}
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
if !path_exists(path).await {
return Ok(());
}
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn remove_dir_all_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_dir_all(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
async fn path_is_dir(path: &Path) -> bool {
tokio::fs::metadata(path)
.await
.is_ok_and(|metadata| metadata.is_dir())
}
async fn path_exists(path: &Path) -> bool {
tokio::fs::metadata(path).await.is_ok()
}
fn local_dir(game_root: &Path) -> PathBuf {
game_root.join(LOCAL_DIR)
}
fn installing_dir(game_root: &Path) -> PathBuf {
game_root.join(INSTALLING_DIR)
}
fn backup_dir(game_root: &Path) -> PathBuf {
game_root.join(BACKUP_DIR)
}
fn owned_marker(path: &Path) -> PathBuf {
path.join(OWNED_MARKER)
}
impl From<bool> for FsEntryState {
fn from(value: bool) -> Self {
if value { Self::Present } else { Self::Missing }
}
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use super::*;
use crate::{install::unpack::UnpackFuture, test_support::TempDir};
#[derive(Default)]
struct FakeUnpacker {
fail: bool,
create_commit_conflict: bool,
archives: Mutex<Vec<PathBuf>>,
}
impl FakeUnpacker {
fn failing() -> Self {
Self {
fail: true,
create_commit_conflict: false,
archives: Mutex::new(Vec::new()),
}
}
fn commit_conflict() -> Self {
Self {
fail: false,
create_commit_conflict: true,
archives: Mutex::new(Vec::new()),
}
}
}
impl Unpacker for FakeUnpacker {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
self.archives
.lock()
.expect("archive list should not be poisoned")
.push(archive.to_path_buf());
if self.fail {
eyre::bail!("forced unpack failure");
}
tokio::fs::write(dest.join("payload.txt"), b"installed").await?;
if self.create_commit_conflict {
let game_root = dest
.parent()
.ok_or_else(|| eyre::eyre!("staging dir should have parent"))?;
let local_conflict = game_root.join(LOCAL_DIR);
tokio::fs::create_dir_all(&local_conflict).await?;
tokio::fs::write(local_conflict.join("conflict.txt"), b"conflict").await?;
}
Ok(())
})
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dir should be created");
}
std::fs::write(path, bytes).expect("file should be written");
}
fn successful_unpacker() -> Arc<dyn Unpacker> {
Arc::new(FakeUnpacker::default())
}
fn test_state() -> TempDir {
TempDir::new("lanspread-install-state")
}
#[tokio::test]
async fn install_success_promotes_staging_and_clears_intent() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(&root, state.path(), "game", successful_unpacker())
.await
.expect("install should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join(".local.installing").exists());
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn install_resets_launch_settings_marker() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
install(&root, state.path(), "game", successful_unpacker())
.await
.expect("install should succeed");
assert!(!launch_settings_applied_path(state.path(), "game").exists());
}
#[tokio::test]
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("b.eti"), b"archive");
write_file(&root.join("a.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
let unpacker = Arc::new(FakeUnpacker::default());
install(&root, state.path(), "game", unpacker.clone())
.await
.expect("install should succeed");
let archives = unpacker
.archives
.lock()
.expect("archive list should not be poisoned")
.iter()
.filter_map(|path| path.file_name()?.to_str().map(ToOwned::to_owned))
.collect::<Vec<_>>();
assert_eq!(archives, vec!["a.eti", "b.eti"]);
}
#[tokio::test]
async fn update_failure_restores_previous_local() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
let err = update(
&root,
state.path(),
"game",
Arc::new(FakeUnpacker::failing()),
)
.await
.expect_err("update should fail");
assert!(err.to_string().contains("forced unpack failure"));
assert!(root.join("local").join("old.txt").is_file());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn update_commit_rename_failure_restores_previous_local() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
let err = update(
&root,
state.path(),
"game",
Arc::new(FakeUnpacker::commit_conflict()),
)
.await
.expect_err("update should fail at commit rename");
assert!(
err.to_string().contains("failed to promote update"),
"{err:?}"
);
assert_eq!(
std::fs::read(root.join("local").join("old.txt"))
.expect("old install should be restored"),
b"old"
);
assert!(!root.join("local").join("conflict.txt").exists());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn update_success_promotes_new_local_and_removes_backup() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
update(&root, state.path(), "game", successful_unpacker())
.await
.expect("update should succeed");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join("local").join("old.txt").exists());
assert!(!root.join(".local.installing").exists());
assert!(!root.join(".local.backup").exists());
assert!(!launch_settings_applied_path(state.path(), "game").exists());
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn uninstall_removes_only_local_install() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("payload.txt"), b"installed");
uninstall(&root, state.path(), "game")
.await
.expect("uninstall should succeed");
assert!(!root.join("local").exists());
assert!(root.join("game.eti").is_file());
assert!(root.join("version.ini").is_file());
}
#[cfg(unix)]
#[tokio::test]
async fn uninstall_delete_failure_restores_backup() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
let locked_dir = root.join("local").join("locked");
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
write_file(&locked_dir.join("payload.txt"), b"locked");
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o500))
.expect("locked dir permissions should be set");
let _err = uninstall(&root, state.path(), "game")
.await
.expect_err("uninstall should fail while deleting backup");
for restored_locked_dir in [
root.join("local").join("locked"),
root.join(".local.backup").join("locked"),
] {
if restored_locked_dir.exists() {
std::fs::set_permissions(
&restored_locked_dir,
std::fs::Permissions::from_mode(0o700),
)
.expect("locked dir permissions should be restored for cleanup");
}
}
assert_eq!(
std::fs::read(root.join("local").join("old.txt"))
.expect("old install should be restored"),
b"old"
);
assert_eq!(
std::fs::read(root.join("local").join("locked").join("payload.txt"))
.expect("locked payload should be restored"),
b"locked"
);
assert!(!root.join(".local.backup").exists());
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
}
#[derive(Clone)]
struct RecoveryCase {
name: &'static str,
intent_state: InstallIntentState,
has_local: bool,
has_installing: bool,
has_backup: bool,
expected_local_payload: Option<&'static [u8]>,
}
const LOCAL_PAYLOAD: &[u8] = b"local";
const INSTALLING_PAYLOAD: &[u8] = b"installing";
const BACKUP_PAYLOAD: &[u8] = b"backup";
fn seed_recovery_case(root: &Path, case: &RecoveryCase) {
write_file(&root.join("version.ini"), b"20250101");
if case.has_local {
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
}
if case.has_installing {
write_file(
&root.join(INSTALLING_DIR).join("payload.txt"),
INSTALLING_PAYLOAD,
);
}
if case.has_backup {
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
}
}
fn assert_recovered_case(root: &Path, case: &RecoveryCase) {
let local_payload = root.join(LOCAL_DIR).join("payload.txt");
match case.expected_local_payload {
Some(expected) => assert_eq!(
std::fs::read(&local_payload)
.unwrap_or_else(|err| panic!("{} local payload: {err}", case.name)),
expected,
"{} local payload",
case.name
),
None => assert!(
!root.join(LOCAL_DIR).exists(),
"{} local dir should be absent",
case.name
),
}
assert!(
!root.join(INSTALLING_DIR).exists(),
"{} installing dir should be absent",
case.name
);
assert!(
!root.join(BACKUP_DIR).exists(),
"{} backup dir should be absent",
case.name
);
}
#[tokio::test]
async fn recovery_covers_install_matrix_rows() {
let cases = [
RecoveryCase {
name: "installing_commit_landed",
intent_state: InstallIntentState::Installing,
has_local: true,
has_installing: false,
has_backup: false,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "installing_staging_only",
intent_state: InstallIntentState::Installing,
has_local: false,
has_installing: true,
has_backup: false,
expected_local_payload: None,
},
RecoveryCase {
name: "installing_before_staging",
intent_state: InstallIntentState::Installing,
has_local: false,
has_installing: false,
has_backup: false,
expected_local_payload: None,
},
RecoveryCase {
name: "updating_restore_backup",
intent_state: InstallIntentState::Updating,
has_local: false,
has_installing: true,
has_backup: true,
expected_local_payload: Some(BACKUP_PAYLOAD),
},
RecoveryCase {
name: "updating_commit_landed_with_stale_dirs",
intent_state: InstallIntentState::Updating,
has_local: true,
has_installing: true,
has_backup: true,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "updating_commit_landed_before_backup_cleanup",
intent_state: InstallIntentState::Updating,
has_local: true,
has_installing: false,
has_backup: true,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "updating_commit_and_cleanup_landed",
intent_state: InstallIntentState::Updating,
has_local: true,
has_installing: false,
has_backup: false,
expected_local_payload: Some(LOCAL_PAYLOAD),
},
RecoveryCase {
name: "uninstalling_delete_backup",
intent_state: InstallIntentState::Uninstalling,
has_local: false,
has_installing: false,
has_backup: true,
expected_local_payload: None,
},
RecoveryCase {
name: "uninstalling_already_clean",
intent_state: InstallIntentState::Uninstalling,
has_local: false,
has_installing: false,
has_backup: false,
expected_local_payload: None,
},
RecoveryCase {
name: "uninstalling_before_fs_work",
intent_state: InstallIntentState::Uninstalling,
has_local: true,
has_installing: false,
has_backup: false,
expected_local_payload: None,
},
];
let state = test_state();
for case in cases {
let temp = TempDir::new("lanspread-install");
let root = temp.game_root();
seed_recovery_case(&root, &case);
write_intent(
state.path(),
"game",
&InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())),
)
.await
.unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name));
recover_game_root(&root, state.path(), "game")
.await
.unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name));
assert_recovered_case(&root, &case);
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None, "{}", case.name);
assert_eq!(
intent.eti_version.as_deref(),
Some("20250101"),
"{}",
case.name
);
}
}
#[tokio::test]
async fn recovery_resets_marker_for_commit_landed_install_or_update() {
let state = test_state();
let cases = [
(
"installing-committed",
InstallIntentState::Installing,
false,
),
("updating-committed", InstallIntentState::Updating, true),
];
for (id, intent_state, has_backup) in cases {
let temp = TempDir::new("lanspread-install");
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
if has_backup {
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
}
write_file(&launch_settings_applied_path(state.path(), id), b"");
write_intent(
state.path(),
id,
&InstallIntent::new(id, intent_state, Some("20250101".into())),
)
.await
.expect("intent should be written");
recover_game_root(&root, state.path(), id)
.await
.expect("recovery should succeed");
assert!(
!launch_settings_applied_path(state.path(), id).exists(),
"{id} marker should be reset"
);
let intent = read_intent(state.path(), id).await;
assert_eq!(intent.state, InstallIntentState::None, "{id}");
}
}
#[tokio::test]
async fn recovery_keeps_marker_when_update_rolls_back() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("version.ini"), b"20250101");
write_file(
&root.join(INSTALLING_DIR).join("payload.txt"),
INSTALLING_PAYLOAD,
);
write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD);
write_file(&launch_settings_applied_path(state.path(), "game"), b"");
write_intent(
state.path(),
"game",
&InstallIntent::new(
"game",
InstallIntentState::Updating,
Some("20250101".into()),
),
)
.await
.expect("intent should be written");
recover_game_root(&root, state.path(), "game")
.await
.expect("recovery should succeed");
assert!(launch_settings_applied_path(state.path(), "game").exists());
assert_eq!(
std::fs::read(root.join(LOCAL_DIR).join("payload.txt"))
.expect("backup payload should be restored"),
BACKUP_PAYLOAD
);
}
#[tokio::test]
async fn none_recovery_leaves_markerless_reserved_dirs_untouched() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join(".local.backup").join("user.txt"), b"user");
recover_game_root(&root, state.path(), "game")
.await
.expect("recovery should succeed");
assert!(root.join(".local.backup").join("user.txt").is_file());
}
#[tokio::test]
async fn download_recovery_sweeps_reserved_version_files() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join(VERSION_TMP_FILE), b"tmp");
write_file(&root.join(VERSION_DISCARDED_FILE), b"old");
recover_game_root(&root, state.path(), "game")
.await
.expect("recovery should succeed");
assert!(!root.join(VERSION_TMP_FILE).exists());
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
}
#[tokio::test]
async fn startup_recovery_skips_active_game_roots() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let active_root = temp.path().join("active");
let inactive_root = temp.path().join("inactive");
write_file(&active_root.join(VERSION_TMP_FILE), b"tmp");
write_file(&inactive_root.join(VERSION_TMP_FILE), b"tmp");
recover_on_startup(
temp.path(),
state.path(),
&HashSet::from(["active".to_string()]),
)
.await
.expect("recovery should succeed");
assert!(active_root.join(VERSION_TMP_FILE).is_file());
assert!(!inactive_root.join(VERSION_TMP_FILE).exists());
}
}