Files
lanspread/crates/lanspread-peer/src/migration.rs
T
ddidderr a1013b028d refactor(peer): centralize game root path policy
Game scanning, manifest validation, install recovery, migration, and download
cleanup each carried their own spellings and case rules for reserved entries.
Those copies had already diverged, which made it possible for one subsystem to
accept or expose a path that another treated as application-owned state.

Introduce one game_paths module for the canonical names and conservative
portable comparison policy. Keep context-specific predicates for manifest and
scanner protection versus cancellation preservation: cancellation still sweeps
its own version transaction scratch files, while install and migration state
survive. Reuse the constants for all production path construction sites.

Test Plan:
- `just test` -- passed (175 lanspread-peer tests and full workspace)
- `just clippy` -- passed
- `just fmt` -- Rust, TOML, and Prettier completed; the recipe remains blocked
  by 39 pre-existing rumdl issues in five unrelated Markdown files
- `git diff --cached --check` -- passed
2026-08-09 17:59:01 +02:00

612 lines
20 KiB
Rust

use std::{
io::ErrorKind,
path::{Path, PathBuf},
time::Instant,
};
use futures::{StreamExt as _, stream};
use tokio::io::AsyncWriteExt as _;
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,
},
install::intent::{InstallIntent, intent_path, write_intent},
local_games::legacy_library_index_path,
state_paths::{local_library_index_path, setup_done_path},
};
const MIGRATION_CONCURRENCY: usize = 16;
#[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 {
let started = Instant::now();
let mut report = MigrationReport::default();
report.merge(migrate_library_index(game_dir, state_dir).await);
let game_roots = match collect_game_roots(game_dir).await {
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;
}
};
let game_reports = stream::iter(game_roots)
.map(|(id, root)| async move { migrate_game_root(state_dir, id, root).await })
.buffer_unordered(MIGRATION_CONCURRENCY)
.collect::<Vec<_>>()
.await;
for game_report in game_reports {
report.merge(game_report);
}
log_migration_report(&report, started);
report
}
async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
let mut roots = Vec::new();
let mut entries = tokio::fs::read_dir(game_dir).await?;
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 is_ignored_games_root_name(&id) {
continue;
}
roots.push((id, entry.path()));
}
Ok(roots)
}
async 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 migrate_raw_file(&legacy_path, &target_path).await {
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)).await);
report.merge(remove_empty_legacy_library_dir(game_dir).await);
report
}
async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport {
let mut report = MigrationReport {
games_checked: 1,
..MigrationReport::default()
};
report.merge(migrate_install_intent(state_dir, &id, &root).await);
report.merge(delete_if_exists(&root.join(LEGACY_INTENT_TMP_FILE)).await);
report.merge(migrate_setup_marker(state_dir, &id, &root).await);
report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)).await);
report.merge(note_unknown_softlan_files(&root).await);
report
}
async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
let legacy_path = root.join(LEGACY_INTENT_FILE);
let target_path = intent_path(state_dir, id);
match path_exists(&legacy_path).await {
Ok(false) => return report,
Ok(true) => {}
Err(err) => {
log::warn!(
"Failed to inspect legacy install intent {}: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
}
match path_exists(&target_path).await {
Ok(true) => {
report.merge(delete_file(&legacy_path).await);
return report;
}
Ok(false) => {}
Err(err) => {
log::warn!(
"Failed to inspect app-state install intent {}: {err}",
target_path.display()
);
report.failures += 1;
return report;
}
}
let data = match tokio::fs::read_to_string(&legacy_path).await {
Ok(data) => data,
Err(err) => {
log::warn!(
"Failed to read legacy install intent {}: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
};
let intent = match serde_json::from_str::<InstallIntent>(&data) {
Ok(intent) if intent.is_current_for(id) => intent,
Ok(intent) => {
log::warn!(
"Leaving legacy install intent {} in place because it belongs to id {} schema {}",
legacy_path.display(),
intent.id,
intent.schema_version
);
report.failures += 1;
return report;
}
Err(err) => {
log::warn!(
"Leaving corrupt legacy install intent {} in place: {err}",
legacy_path.display()
);
report.failures += 1;
return report;
}
};
if let Err(err) = write_intent(state_dir, id, &intent).await {
log::warn!(
"Failed to write migrated install intent {}: {err}",
target_path.display()
);
report.failures += 1;
return report;
}
report.install_intents_migrated += 1;
report.merge(delete_file(&legacy_path).await);
report
}
async 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);
let target_path = setup_done_path(state_dir, id);
match migrate_empty_marker(&legacy_path, &target_path).await {
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
}
async fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
let mut report = MigrationReport::default();
report.unknown_softlan_files += count_unknown_softlan_files(root).await;
report.unknown_softlan_files += count_unknown_softlan_files(&root.join("local")).await;
report
}
async fn count_unknown_softlan_files(dir: &Path) -> usize {
let mut count = 0;
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
Err(err) => {
log::warn!(
"Failed to inspect {} for legacy .softlan files: {err}",
dir.display()
);
return 0;
}
};
while let Ok(Some(entry)) = entries.next_entry().await {
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;
log::info!(
"Leaving unknown legacy .softlan file in place: {}",
entry.path().display()
);
}
count
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MigrationOutcome {
SourceMissing,
TargetAlreadyExists,
Migrated,
}
async fn migrate_raw_file(
legacy_path: &Path,
target_path: &Path,
) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path).await? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path).await? {
remove_file_if_exists(legacy_path).await?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
let data = tokio::fs::read(legacy_path).await?;
write_bytes_atomically(target_path, &data).await?;
remove_file_if_exists(legacy_path).await?;
Ok(MigrationOutcome::Migrated)
}
async fn migrate_empty_marker(
legacy_path: &Path,
target_path: &Path,
) -> std::io::Result<MigrationOutcome> {
if !path_exists(legacy_path).await? {
return Ok(MigrationOutcome::SourceMissing);
}
if path_exists(target_path).await? {
remove_file_if_exists(legacy_path).await?;
return Ok(MigrationOutcome::TargetAlreadyExists);
}
if let Some(parent) = target_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::File::create(target_path)
.await?
.sync_all()
.await?;
remove_file_if_exists(legacy_path).await?;
Ok(MigrationOutcome::Migrated)
}
async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp_path = library_index_tmp_path(path);
let mut file = tokio::fs::File::create(&tmp_path).await?;
file.write_all(data).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&tmp_path, path).await?;
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)
}
async fn path_exists(path: &Path) -> std::io::Result<bool> {
match tokio::fs::metadata(path).await {
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
async fn delete_if_exists(path: &Path) -> MigrationReport {
match remove_file_if_exists(path).await {
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()
}
}
}
}
async fn delete_file(path: &Path) -> MigrationReport {
match remove_file_if_exists(path).await {
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()
}
}
}
}
async fn remove_file_if_exists(path: &Path) -> std::io::Result<bool> {
if !path_exists(path).await? {
return Ok(false);
}
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
async fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport {
let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR);
let exists = match path_exists(&path).await {
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 tokio::fs::remove_dir(&path).await {
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::{InstallIntentState, read_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":7,"games":{}}"#);
write_file(&legacy_tmp_path, b"tmp");
let report = migrate_legacy_state(games.path(), state.path()).await;
assert!(report.library_index_migrated);
assert_eq!(
std::fs::read_to_string(&target_path).expect("index should migrate"),
r#"{"revision":7,"games":{}}"#
);
assert!(!legacy_path.exists());
assert!(!legacy_tmp_path.exists());
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
}
#[tokio::test]
async fn migrates_per_game_intent_and_setup_marker() {
let games = TempDir::new("lanspread-migration-games");
let state = TempDir::new("lanspread-migration-state");
let root = games.path().join("game");
let intent = InstallIntent::new(
"game",
InstallIntentState::Updating,
Some("20250101".to_string()),
);
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,
&serde_json::to_vec_pretty(&intent).expect("intent should serialize"),
);
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, 1);
assert_eq!(report.setup_markers_migrated, 1);
let migrated_intent = read_intent(state.path(), "game").await;
assert_eq!(migrated_intent.state, InstallIntentState::Updating);
assert_eq!(migrated_intent.eti_version.as_deref(), Some("20250101"));
assert!(setup_done_path(state.path(), "game").is_file());
assert!(!legacy_intent.exists());
assert!(!legacy_tmp.exists());
assert!(!legacy_setup.exists());
assert!(!legacy_marker.exists());
}
#[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("game", Some("app".to_string()));
let legacy_intent = InstallIntent::new(
"game",
InstallIntentState::Installing,
Some("legacy".to_string()),
);
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)
.await
.expect("app-state intent should be written");
write_file(
&legacy_intent_path,
&serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"),
);
write_file(&setup_done_path(state.path(), "game"), 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.setup_markers_migrated, 0);
let intent = read_intent(state.path(), "game").await;
assert_eq!(intent.state, InstallIntentState::None);
assert_eq!(intent.eti_version.as_deref(), Some("app"));
assert!(!legacy_intent_path.exists());
assert!(!legacy_setup.exists());
}
}