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, ) -> 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, ) -> 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, ) -> 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, ) -> 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) -> 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, ) -> 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> { 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 { 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 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>, } 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 { 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::>(); 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()); } }