use std::{ fs, io::{ErrorKind, Read as _, Write as _}, path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, thread, time::{Duration, Instant}, }; use lanspread_db::content_manifest::{MAX_CATALOG_ENTRIES, MAX_CATALOG_MANIFEST_BYTES}; use crate::{ game_paths::{ LEGACY_FIRST_START_DONE_FILE, LEGACY_INTENT_FILE, LEGACY_INTENT_TMP_FILE, LEGACY_LIBRARY_INDEX_DIR, LEGACY_SOFTLAN_INSTALL_MARKER, is_ignored_games_root_name, }, local_games::{legacy_library_index_path, normalize_migrated_library_index}, scoped_blocking::scoped_blocking, state_paths::{local_library_index_path, setup_done_path}, }; const MIGRATION_CONCURRENCY: usize = 16; const MAX_LEGACY_LIBRARY_INDEX_BYTES: u64 = MAX_CATALOG_MANIFEST_BYTES; const MAX_MIGRATION_SCAN_ENTRIES: usize = MAX_CATALOG_ENTRIES * 10 + 1; const MAX_MIGRATION_SCAN_DURATION: Duration = Duration::from_mins(10); #[derive(Clone, Copy)] struct MigrationScanLimits { entry_cap: usize, time_budget: Duration, } impl MigrationScanLimits { const PRODUCTION: Self = Self { entry_cap: MAX_MIGRATION_SCAN_ENTRIES, time_budget: MAX_MIGRATION_SCAN_DURATION, }; } struct MigrationGameRoot { id: String, path: PathBuf, unknown_softlan_files: usize, } struct MigrationScanBudget { limits: MigrationScanLimits, entries: usize, started: Instant, } impl MigrationScanBudget { fn new(limits: MigrationScanLimits) -> Self { Self { limits, entries: 0, started: Instant::now(), } } fn charge(&mut self) -> std::io::Result<()> { self.entries = self.entries.checked_add(1).ok_or_else(|| { std::io::Error::new(ErrorKind::InvalidData, "migration entry count overflow") })?; if self.entries > self.limits.entry_cap { return Err(std::io::Error::new( ErrorKind::InvalidData, format!( "legacy migration exceeds the {}-entry scan budget", self.limits.entry_cap ), )); } if self.started.elapsed() >= self.limits.time_budget { return Err(std::io::Error::new( ErrorKind::TimedOut, format!( "legacy migration exceeds the {:?} scan-time budget", self.limits.time_budget ), )); } Ok(()) } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)] pub struct MigrationReport { pub games_checked: usize, pub library_index_migrated: bool, pub install_intents_migrated: usize, pub setup_markers_migrated: usize, pub legacy_files_deleted: usize, pub unknown_softlan_files: usize, pub failures: usize, } impl MigrationReport { fn merge(&mut self, other: Self) { self.games_checked += other.games_checked; self.library_index_migrated |= other.library_index_migrated; self.install_intents_migrated += other.install_intents_migrated; self.setup_markers_migrated += other.setup_markers_migrated; self.legacy_files_deleted += other.legacy_files_deleted; self.unknown_softlan_files += other.unknown_softlan_files; self.failures += other.failures; } } /// Migrates legacy app-owned files out of the configured game directory. /// /// This is intentionally separate from normal operation: callers should run it /// before starting the peer runtime for a game directory. pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport { // Keep the public asynchronous boundary cooperative before entering the // finite, lexically scoped filesystem batch. tokio::task::yield_now().await; migrate_legacy_state_with_limits(game_dir, state_dir, MigrationScanLimits::PRODUCTION) } fn migrate_legacy_state_with_limits( game_dir: &Path, state_dir: &Path, limits: MigrationScanLimits, ) -> MigrationReport { let started = Instant::now(); let mut report = MigrationReport::default(); let game_roots = match scoped_blocking(|| collect_game_roots(game_dir, limits)) { Ok(game_roots) => game_roots, Err(err) => { if err.kind() != ErrorKind::NotFound { log::warn!( "Failed to enumerate game roots for legacy state migration in {}: {err}", game_dir.display() ); report.failures += 1; } log_migration_report(&report, started); return report; } }; // Complete the selected-root enumeration before mutating either legacy or // current state. A budget failure therefore leaves the prior migration // generation intact instead of committing a truncated subset. report.merge(migrate_library_index(game_dir, state_dir)); report.merge(scoped_blocking(|| { migrate_game_roots(state_dir, &game_roots) })); log_migration_report(&report, started); report } fn collect_game_roots( game_dir: &Path, limits: MigrationScanLimits, ) -> std::io::Result> { let mut roots = Vec::new(); let mut budget = MigrationScanBudget::new(limits); for entry in fs::read_dir(game_dir)? { let entry = entry?; budget.charge()?; if !entry.file_type()?.is_dir() { continue; } let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if is_ignored_games_root_name(&id) { continue; } let path = entry.path(); let unknown_softlan_files = count_unknown_softlan_files(&path, &mut budget)? .checked_add(count_unknown_softlan_files( &path.join("local"), &mut budget, )?) .ok_or_else(|| { std::io::Error::new(ErrorKind::InvalidData, "legacy marker count overflow") })?; roots.push(MigrationGameRoot { id, path, unknown_softlan_files, }); } Ok(roots) } fn migrate_game_roots(state_dir: &Path, game_roots: &[MigrationGameRoot]) -> MigrationReport { if game_roots.is_empty() { return MigrationReport::default(); } let next_root = AtomicUsize::new(0); let worker_count = game_roots.len().min(MIGRATION_CONCURRENCY); // Scoped workers preserve the former bounded overlap while guaranteeing // that success, cancellation, and panic cannot leave filesystem work behind. thread::scope(|scope| { let mut workers = Vec::with_capacity(worker_count); for _ in 0..worker_count { workers.push(scope.spawn(|| { let mut report = MigrationReport::default(); loop { let index = next_root.fetch_add(1, Ordering::Relaxed); let Some(root) = game_roots.get(index) else { break; }; report.merge(migrate_game_root(state_dir, root)); } report })); } let mut report = MigrationReport::default(); for worker in workers { match worker.join() { Ok(worker_report) => report.merge(worker_report), Err(payload) => std::panic::resume_unwind(payload), } } report }) } fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport { let mut report = MigrationReport::default(); let legacy_path = legacy_library_index_path(game_dir); let target_path = local_library_index_path(state_dir); match scoped_blocking(|| migrate_library_index_file(&legacy_path, &target_path)) { Ok(MigrationOutcome::Migrated) => { report.library_index_migrated = true; report.legacy_files_deleted += 1; } Ok(MigrationOutcome::TargetAlreadyExists) => { report.legacy_files_deleted += 1; } Ok(MigrationOutcome::SourceMissing) => {} Err(err) => { log::warn!( "Failed to migrate legacy library index {} to {}: {err}", legacy_path.display(), target_path.display() ); report.failures += 1; } } report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path))); report.merge(remove_empty_legacy_library_dir(game_dir)); report } fn migrate_game_root(state_dir: &Path, game_root: &MigrationGameRoot) -> MigrationReport { let MigrationGameRoot { id, path: root, unknown_softlan_files, } = game_root; let mut report = MigrationReport { games_checked: 1, unknown_softlan_files: *unknown_softlan_files, ..MigrationReport::default() }; report.merge(note_legacy_install_intent(root)); report.merge(migrate_setup_marker(state_dir, id, root)); report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER))); report } fn note_legacy_install_intent(root: &Path) -> MigrationReport { let mut report = MigrationReport::default(); for name in [LEGACY_INTENT_FILE, LEGACY_INTENT_TMP_FILE] { let path = root.join(name); match scoped_blocking(|| path_exists(&path)) { Ok(false) => {} Ok(true) => { log::warn!( "Leaving unsupported legacy install intent in place: {}", path.display() ); report.failures += 1; } Err(error) => { log::warn!( "Failed to inspect legacy install intent {}: {error}", path.display() ); report.failures += 1; } } } report } fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport { let mut report = MigrationReport::default(); let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); // Roots are discovered from directory names in the games folder, so an // arbitrary name must be refused here rather than joined below the state // directory. let target_path = match setup_done_path(state_dir, id) { Ok(path) => path, Err(error) => { log::warn!("Refusing setup marker migration for unsafe game ID {id:?}: {error}"); report.failures += 1; return report; } }; match scoped_blocking(|| migrate_empty_marker(&legacy_path, &target_path)) { Ok(MigrationOutcome::Migrated) => { report.setup_markers_migrated += 1; report.legacy_files_deleted += 1; } Ok(MigrationOutcome::TargetAlreadyExists) => { report.legacy_files_deleted += 1; } Ok(MigrationOutcome::SourceMissing) => {} Err(err) => { log::warn!( "Failed to migrate legacy setup marker {} to {}: {err}", legacy_path.display(), target_path.display() ); report.failures += 1; } } report } fn count_unknown_softlan_files( dir: &Path, budget: &mut MigrationScanBudget, ) -> std::io::Result { let mut count = 0; let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => return Ok(0), Err(err) => return Err(err), }; for entry in entries { let entry = entry?; budget.charge()?; let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if !name.starts_with(".softlan_") || name == LEGACY_SOFTLAN_INSTALL_MARKER || name == LEGACY_FIRST_START_DONE_FILE { continue; } count += 1; } Ok(count) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum MigrationOutcome { SourceMissing, TargetAlreadyExists, Migrated, } fn migrate_library_index_file( legacy_path: &Path, target_path: &Path, ) -> std::io::Result { if !path_exists(legacy_path)? { return Ok(MigrationOutcome::SourceMissing); } if path_exists(target_path)? { remove_file_if_exists(legacy_path)?; return Ok(MigrationOutcome::TargetAlreadyExists); } let data = read_bounded_regular_file(legacy_path, MAX_LEGACY_LIBRARY_INDEX_BYTES)?; let data = normalize_migrated_library_index(&data) .map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?; write_bytes_atomically(target_path, &data)?; remove_file_if_exists(legacy_path)?; Ok(MigrationOutcome::Migrated) } fn read_bounded_regular_file(path: &Path, limit: u64) -> std::io::Result> { let before = fs::symlink_metadata(path)?; if !before.is_file() || is_link_or_reparse(&before) || before.len() > limit { return Err(std::io::Error::new( ErrorKind::InvalidData, "legacy library index is not a bounded regular file", )); } let mut file = fs::File::open(path)?; let opened = file.metadata()?; if !opened.is_file() || !same_file(&before, &opened) || opened.len() > limit { return Err(std::io::Error::new( ErrorKind::InvalidData, "legacy library index changed while opening", )); } let capacity = usize::try_from(opened.len().min(limit)) .map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?; let mut bytes = Vec::with_capacity(capacity); std::io::Read::by_ref(&mut file) .take(limit + 1) .read_to_end(&mut bytes)?; if u64::try_from(bytes.len()).map_or(true, |length| length > limit) { return Err(std::io::Error::new( ErrorKind::InvalidData, "legacy library index exceeds its byte limit", )); } let after = file.metadata()?; if !after.is_file() || !same_file(&before, &after) || after.len() != opened.len() { return Err(std::io::Error::new( ErrorKind::InvalidData, "legacy library index changed while reading", )); } Ok(bytes) } #[cfg(unix)] fn same_file(before: &fs::Metadata, after: &fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt as _; before.dev() == after.dev() && before.ino() == after.ino() } #[cfg(windows)] fn same_file(before: &fs::Metadata, after: &fs::Metadata) -> bool { use std::os::windows::fs::MetadataExt as _; before.volume_serial_number().is_some() && before.volume_serial_number() == after.volume_serial_number() && before.file_index().is_some() && before.file_index() == after.file_index() } #[cfg(not(any(unix, windows)))] const fn same_file(_before: &fs::Metadata, _after: &fs::Metadata) -> bool { false } #[cfg(windows)] fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { use std::os::windows::fs::MetadataExt as _; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; metadata.file_type().is_symlink() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 } #[cfg(not(windows))] fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { metadata.file_type().is_symlink() } fn migrate_empty_marker( legacy_path: &Path, target_path: &Path, ) -> std::io::Result { if !path_exists(legacy_path)? { return Ok(MigrationOutcome::SourceMissing); } if path_exists(target_path)? { remove_file_if_exists(legacy_path)?; return Ok(MigrationOutcome::TargetAlreadyExists); } if let Some(parent) = target_path.parent() { fs::create_dir_all(parent)?; } fs::File::create(target_path)?.sync_all()?; remove_file_if_exists(legacy_path)?; Ok(MigrationOutcome::Migrated) } fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let tmp_path = library_index_tmp_path(path); let mut file = fs::File::create(&tmp_path)?; file.write_all(data)?; file.sync_all()?; drop(file); fs::rename(&tmp_path, path)?; sync_parent_dir(path) } fn library_index_tmp_path(path: &Path) -> PathBuf { let Some(file_name) = path.file_name() else { return path.with_extension("tmp"); }; let mut tmp_name = file_name.to_os_string(); tmp_name.push(".tmp"); path.with_file_name(tmp_name) } fn path_exists(path: &Path) -> std::io::Result { match fs::metadata(path) { Ok(_) => Ok(true), Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), Err(err) => Err(err), } } fn delete_if_exists(path: &Path) -> MigrationReport { match scoped_blocking(|| remove_file_if_exists(path)) { Ok(true) => MigrationReport { legacy_files_deleted: 1, ..MigrationReport::default() }, Ok(false) => MigrationReport::default(), Err(err) => { log::warn!("Failed to delete legacy file {}: {err}", path.display()); MigrationReport { failures: 1, ..MigrationReport::default() } } } } fn remove_file_if_exists(path: &Path) -> std::io::Result { if !path_exists(path)? { return Ok(false); } match fs::remove_file(path) { Ok(()) => Ok(true), Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), Err(err) => Err(err), } } fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport { let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR); scoped_blocking(|| { let exists = match path_exists(&path) { Ok(exists) => exists, Err(err) => { log::warn!( "Failed to inspect legacy library index directory {}: {err}", path.display() ); return MigrationReport { failures: 1, ..MigrationReport::default() }; } }; if !exists { return MigrationReport::default(); } match fs::remove_dir(&path) { Ok(()) => MigrationReport { legacy_files_deleted: 1, ..MigrationReport::default() }, Err(err) if err.kind() == ErrorKind::NotFound || err.kind() == ErrorKind::DirectoryNotEmpty => { MigrationReport::default() } Err(err) => { log::warn!( "Failed to remove empty legacy library index directory {}: {err}", path.display() ); MigrationReport { failures: 1, ..MigrationReport::default() } } } }) } fn log_migration_report(report: &MigrationReport, started: Instant) { log::info!( "Legacy state migration finished in {:?}: games_checked={}, library_index_migrated={}, \ install_intents_migrated={}, setup_markers_migrated={}, legacy_files_deleted={}, \ unknown_softlan_files={}, failures={}", started.elapsed(), report.games_checked, report.library_index_migrated, report.install_intents_migrated, report.setup_markers_migrated, report.legacy_files_deleted, report.unknown_softlan_files, report.failures ); } #[cfg(unix)] fn sync_parent_dir(path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::File::open(parent)?.sync_all()?; } Ok(()) } #[cfg(not(unix))] fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::{ install::intent::{ InstallIntent, InstallIntentState, LoadedInstallIntent, read_intent, write_intent, }, test_support::TempDir, }; 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"); } #[tokio::test] async fn migrates_legacy_library_index_to_app_state() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let legacy_path = legacy_library_index_path(games.path()); let target_path = local_library_index_path(state.path()); let legacy_tmp_path = library_index_tmp_path(&legacy_path); write_file( &legacy_path, br#"{"revision":18446744073709551615,"games":{}}"#, ); write_file(&legacy_tmp_path, b"tmp"); let report = migrate_legacy_state(games.path(), state.path()).await; assert!(report.library_index_migrated); let migrated: serde_json::Value = serde_json::from_slice(&std::fs::read(&target_path).expect("index should migrate")) .expect("migrated index should remain valid JSON"); assert_eq!(migrated["revision"], 0); assert_eq!(migrated["games"], serde_json::json!({})); assert!(!legacy_path.exists()); assert!(!legacy_tmp_path.exists()); assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists()); } #[tokio::test] async fn oversized_legacy_library_index_is_left_unmigrated() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let legacy_path = legacy_library_index_path(games.path()); let target_path = local_library_index_path(state.path()); std::fs::create_dir_all(legacy_path.parent().expect("legacy index has a parent")) .expect("legacy parent should be created"); std::fs::File::create(&legacy_path) .expect("legacy index should be created") .set_len(MAX_LEGACY_LIBRARY_INDEX_BYTES + 1) .expect("legacy index should become oversized"); let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.failures, 1); assert!(!report.library_index_migrated); assert!(legacy_path.exists()); assert!(!target_path.exists()); } #[tokio::test] async fn selected_root_budget_failure_precedes_every_migration_mutation() { let games = TempDir::new("lanspread-migration-budget-games"); let state = TempDir::new("lanspread-migration-budget-state"); let legacy_index = legacy_library_index_path(games.path()); let legacy_marker = games .path() .join("game") .join(LEGACY_SOFTLAN_INSTALL_MARKER); write_file(&legacy_index, br#"{"revision":7,"games":{}}"#); write_file(&legacy_marker, b""); let report = migrate_legacy_state_with_limits( games.path(), state.path(), MigrationScanLimits { entry_cap: 0, time_budget: MAX_MIGRATION_SCAN_DURATION, }, ); assert_eq!(report.failures, 1); assert!(!report.library_index_migrated); assert!(legacy_index.exists(), "legacy index must remain in place"); assert!( legacy_marker.exists(), "per-game marker must remain in place" ); assert!( !local_library_index_path(state.path()).exists(), "budget failure must not publish a partial current index" ); } #[test] fn migration_scan_time_budget_is_explicit() { let mut budget = MigrationScanBudget::new(MigrationScanLimits { entry_cap: 1, time_budget: Duration::ZERO, }); let error = budget .charge() .expect_err("zero-duration migration scan must fail immediately"); assert_eq!(error.kind(), ErrorKind::TimedOut); assert!(error.to_string().contains("scan-time budget")); } #[tokio::test] async fn existing_app_index_wins_without_reading_oversized_legacy_bytes() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let legacy_path = legacy_library_index_path(games.path()); let target_path = local_library_index_path(state.path()); std::fs::create_dir_all(legacy_path.parent().expect("legacy index has a parent")) .expect("legacy parent should be created"); std::fs::File::create(&legacy_path) .expect("legacy index should be created") .set_len(MAX_LEGACY_LIBRARY_INDEX_BYTES + 1) .expect("legacy index should become oversized"); write_file(&target_path, br#"{"revision":9,"games":{}}"#); let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.failures, 0); assert!(!report.library_index_migrated); assert!(!legacy_path.exists()); assert_eq!( std::fs::read_to_string(target_path).expect("existing index should remain"), r#"{"revision":9,"games":{}}"#, ); } #[tokio::test] async fn legacy_install_intent_is_rejected_without_deletion() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let root = games.path().join("game"); let legacy_intent = root.join(LEGACY_INTENT_FILE); let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE); let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); let legacy_marker = root.join(LEGACY_SOFTLAN_INSTALL_MARKER); write_file( &legacy_intent, br#"{"schema_version":1,"state":"Updating"}"#, ); write_file(&legacy_tmp, b"tmp"); write_file(&legacy_setup, b""); write_file(&legacy_marker, b""); let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.install_intents_migrated, 0); assert_eq!(report.failures, 2); assert_eq!(report.setup_markers_migrated, 1); assert!( setup_done_path(state.path(), "game") .expect("setup marker path should be valid") .is_file() ); assert!(legacy_intent.exists()); assert!(legacy_tmp.exists()); assert!(!legacy_setup.exists()); assert!(!legacy_marker.exists()); } #[tokio::test] async fn migrates_multiple_roots_and_second_run_is_idempotent() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let root_count = MIGRATION_CONCURRENCY + 3; for index in 0..root_count { write_file( &games .path() .join(format!("game-{index}")) .join(LEGACY_SOFTLAN_INSTALL_MARKER), b"", ); } let first = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(first.games_checked, root_count); assert_eq!(first.legacy_files_deleted, root_count); assert_eq!(first.failures, 0); let second = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(second.games_checked, root_count); assert_eq!(second.legacy_files_deleted, 0); assert_eq!(second.failures, 0); } #[tokio::test] async fn unsafe_root_name_is_refused_without_panicking_or_touching_state() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); // A trailing dot is a legal directory name on Unix but is not a // portable game ID; joining it below the state directory must be // refused rather than attempted. let legacy_marker = games .path() .join("game.") .join("local") .join(LEGACY_FIRST_START_DONE_FILE); write_file(&legacy_marker, b""); let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.games_checked, 1); assert_eq!(report.failures, 1); assert_eq!(report.setup_markers_migrated, 0); assert!(legacy_marker.exists(), "legacy marker must be kept"); assert!( !state.path().join("games").exists(), "no per-game state may be created for an unsafe ID" ); } #[tokio::test] async fn app_state_wins_over_legacy_per_game_state() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let root = games.path().join("game"); let app_intent = InstallIntent::none(&root, "game", Some("app".to_string())) .expect("intent root should resolve"); let legacy_intent_path = root.join(LEGACY_INTENT_FILE); let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); write_intent(state.path(), "game", &app_intent) .expect("app-state intent should be written"); write_file( &legacy_intent_path, br#"{"schema_version":1,"state":"Installing"}"#, ); write_file( &setup_done_path(state.path(), "game").expect("setup marker path should be valid"), b"", ); write_file(&legacy_setup, b""); let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.install_intents_migrated, 0); assert_eq!(report.failures, 1); assert_eq!(report.setup_markers_migrated, 0); let LoadedInstallIntent::Valid(intent) = read_intent(state.path(), &root, "game") else { panic!("current app-state intent should remain valid"); }; assert_eq!(intent.state, InstallIntentState::None); assert_eq!(intent.eti_version.as_deref(), Some("app")); assert!(legacy_intent_path.exists()); assert!(!legacy_setup.exists()); } }