Bound legacy traversal by entry count and cooperative elapsed time, and complete the read-only plan before mutating any source or destination. Reuse the catalog-sized legacy-index read ceiling. Test Plan: - just test - just clippy - focused preflight and oversized migration tests - git diff --check
900 lines
30 KiB
Rust
900 lines
30 KiB
Rust
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<Vec<MigrationGameRoot>> {
|
|
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<usize> {
|
|
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<MigrationOutcome> {
|
|
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<Vec<u8>> {
|
|
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<MigrationOutcome> {
|
|
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<bool> {
|
|
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<bool> {
|
|
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());
|
|
}
|
|
}
|