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; 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, id: &str, unpacker: Arc) -> eyre::Result<()> { let eti_version = read_downloaded_version(game_root).await; write_intent( game_root, &InstallIntent::new(id, InstallIntentState::Installing, eti_version.clone()), ) .await?; let result = install_inner(game_root, id, unpacker).await; match result { Ok(()) => { write_intent(game_root, &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(game_root, &InstallIntent::none(id, eti_version)).await?; Err(err) } } } pub async fn update(game_root: &Path, id: &str, unpacker: Arc) -> eyre::Result<()> { let eti_version = read_downloaded_version(game_root).await; write_intent( game_root, &InstallIntent::new(id, InstallIntentState::Updating, eti_version.clone()), ) .await?; let result = update_inner(game_root, id, unpacker).await; match result { Ok(()) => { write_intent(game_root, &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(game_root, &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, id: &str) -> eyre::Result<()> { let eti_version = read_downloaded_version(game_root).await; write_intent( game_root, &InstallIntent::new(id, InstallIntentState::Uninstalling, eti_version.clone()), ) .await?; let result = uninstall_inner(game_root).await; match result { Ok(()) => { write_intent(game_root, &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(game_root, &InstallIntent::none(id, eti_version)).await?; Err(err) } } } pub async fn recover_on_startup(game_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(), &id).await?; } Ok(()) } pub async fn recover_game_root(game_root: &Path, id: &str) -> eyre::Result<()> { recover_download_transients(game_root).await?; let intent = read_intent(game_root, 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, id, intent, fs).await?, InstallIntentState::Updating => recover_updating(game_root, id, intent, fs).await?, InstallIntentState::Uninstalling => recover_uninstalling(game_root, 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) } 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, id: &str, intent: InstallIntent, fs: InstallFsState, ) -> eyre::Result<()> { if let InstallFsState { installing: FsEntryState::Present, .. } = fs { remove_dir_all_if_exists(&installing_dir(game_root)).await?; } write_intent(game_root, &InstallIntent::none(id, intent.eti_version)).await } async fn recover_updating( game_root: &Path, id: &str, intent: InstallIntent, fs: InstallFsState, ) -> eyre::Result<()> { 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(game_root, &InstallIntent::none(id, intent.eti_version)).await } async fn recover_uninstalling( game_root: &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(game_root, &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<()> { 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()) } 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; #[derive(Default)] struct FakeUnpacker { fail: bool, archives: Mutex>, } impl FakeUnpacker { fn failing() -> Self { Self { fail: 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?; Ok(()) }) } } struct TempDir(PathBuf); impl TempDir { fn new() -> Self { let mut path = std::env::temp_dir(); path.push(format!( "lanspread-install-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); std::fs::create_dir_all(&path).expect("temp dir should be created"); Self(path) } fn game_root(&self) -> PathBuf { self.0.join("game") } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } 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()) } #[tokio::test] async fn install_success_promotes_staging_and_clears_intent() { let temp = TempDir::new(); let root = temp.game_root(); write_file(&root.join("game.eti"), b"archive"); write_file(&root.join("version.ini"), b"20250101"); install(&root, "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(&root, "game").await; assert_eq!(intent.state, InstallIntentState::None); } #[tokio::test] async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() { let temp = TempDir::new(); 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, "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(); 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, "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(&root, "game").await; assert_eq!(intent.state, InstallIntentState::None); } #[tokio::test] async fn update_success_promotes_new_local_and_removes_backup() { let temp = TempDir::new(); 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"); update(&root, "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()); let intent = read_intent(&root, "game").await; assert_eq!(intent.state, InstallIntentState::None); } #[tokio::test] async fn uninstall_removes_only_local_install() { let temp = TempDir::new(); 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, "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()); } #[tokio::test] async fn recovery_restores_backup_for_interrupted_update() { let temp = TempDir::new(); let root = temp.game_root(); write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join(".local.backup").join("old.txt"), b"old"); write_file(&root.join(".local.installing").join("new.txt"), b"new"); write_file(&root.join(".local.backup").join(OWNED_MARKER), b""); write_file(&root.join(".local.installing").join(OWNED_MARKER), b""); write_intent( &root, &InstallIntent::new( "game", InstallIntentState::Updating, Some("20250101".into()), ), ) .await .expect("intent should be written"); recover_game_root(&root, "game") .await .expect("recovery should succeed"); assert!(root.join("local").join("old.txt").is_file()); assert!(!root.join(".local.installing").exists()); assert!(!root.join(".local.backup").exists()); assert_eq!( read_intent(&root, "game").await.state, InstallIntentState::None ); } #[tokio::test] async fn none_recovery_leaves_markerless_reserved_dirs_untouched() { let temp = TempDir::new(); let root = temp.game_root(); write_file(&root.join(".local.backup").join("user.txt"), b"user"); recover_game_root(&root, "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(); 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, "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(); let active_root = temp.0.join("active"); let inactive_root = temp.0.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.0, &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()); } }