feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
use std::{
|
||||
io::ErrorKind,
|
||||
fs,
|
||||
io::{ErrorKind, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
thread,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use futures::{StreamExt as _, stream};
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
use crate::{
|
||||
game_paths::{
|
||||
LEGACY_FIRST_START_DONE_FILE,
|
||||
@@ -16,8 +16,8 @@ use crate::{
|
||||
LEGACY_SOFTLAN_INSTALL_MARKER,
|
||||
is_ignored_games_root_name,
|
||||
},
|
||||
install::intent::{InstallIntent, intent_path, write_intent},
|
||||
local_games::legacy_library_index_path,
|
||||
scoped_blocking::scoped_blocking,
|
||||
state_paths::{local_library_index_path, setup_done_path},
|
||||
};
|
||||
|
||||
@@ -54,9 +54,9 @@ pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> Migratio
|
||||
let started = Instant::now();
|
||||
let mut report = MigrationReport::default();
|
||||
|
||||
report.merge(migrate_library_index(game_dir, state_dir).await);
|
||||
report.merge(migrate_library_index(game_dir, state_dir));
|
||||
|
||||
let game_roots = match collect_game_roots(game_dir).await {
|
||||
let game_roots = match scoped_blocking(|| collect_game_roots(game_dir)) {
|
||||
Ok(game_roots) => game_roots,
|
||||
Err(err) => {
|
||||
if err.kind() != ErrorKind::NotFound {
|
||||
@@ -71,25 +71,19 @@ pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> Migratio
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
report.merge(scoped_blocking(|| {
|
||||
migrate_game_roots(state_dir, &game_roots)
|
||||
}));
|
||||
|
||||
log_migration_report(&report, started);
|
||||
report
|
||||
}
|
||||
|
||||
async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
|
||||
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() {
|
||||
for entry in fs::read_dir(game_dir)? {
|
||||
let entry = entry?;
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -105,12 +99,48 @@ async fn collect_game_roots(game_dir: &Path) -> std::io::Result<Vec<(String, Pat
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
||||
fn migrate_game_roots(state_dir: &Path, game_roots: &[(String, PathBuf)]) -> 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((id, root)) = game_roots.get(index) else {
|
||||
break;
|
||||
};
|
||||
report.merge(migrate_game_root(state_dir, id, 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 migrate_raw_file(&legacy_path, &target_path).await {
|
||||
match scoped_blocking(|| migrate_raw_file(&legacy_path, &target_path)) {
|
||||
Ok(MigrationOutcome::Migrated) => {
|
||||
report.library_index_migrated = true;
|
||||
report.legacy_files_deleted += 1;
|
||||
@@ -129,114 +159,56 @@ async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationRe
|
||||
}
|
||||
}
|
||||
|
||||
report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)).await);
|
||||
report.merge(remove_empty_legacy_library_dir(game_dir).await);
|
||||
report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)));
|
||||
report.merge(remove_empty_legacy_library_dir(game_dir));
|
||||
report
|
||||
}
|
||||
|
||||
async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport {
|
||||
fn migrate_game_root(state_dir: &Path, id: &str, root: &Path) -> 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.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.merge(note_unknown_softlan_files(root));
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport {
|
||||
fn note_legacy_install_intent(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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
match scoped_blocking(|| migrate_empty_marker(&legacy_path, &target_path)) {
|
||||
Ok(MigrationOutcome::Migrated) => {
|
||||
report.setup_markers_migrated += 1;
|
||||
report.legacy_files_deleted += 1;
|
||||
@@ -258,16 +230,18 @@ async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> Migrat
|
||||
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
|
||||
fn note_unknown_softlan_files(root: &Path) -> MigrationReport {
|
||||
MigrationReport {
|
||||
unknown_softlan_files: scoped_blocking(|| {
|
||||
count_unknown_softlan_files(root) + count_unknown_softlan_files(&root.join("local"))
|
||||
}),
|
||||
..MigrationReport::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn count_unknown_softlan_files(dir: &Path) -> usize {
|
||||
fn count_unknown_softlan_files(dir: &Path) -> usize {
|
||||
let mut count = 0;
|
||||
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return 0,
|
||||
Err(err) => {
|
||||
@@ -279,7 +253,8 @@ async fn count_unknown_softlan_files(dir: &Path) -> usize {
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
for entry in entries {
|
||||
let Ok(entry) = entry else { break };
|
||||
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
@@ -306,61 +281,55 @@ enum MigrationOutcome {
|
||||
Migrated,
|
||||
}
|
||||
|
||||
async fn migrate_raw_file(
|
||||
legacy_path: &Path,
|
||||
target_path: &Path,
|
||||
) -> std::io::Result<MigrationOutcome> {
|
||||
if !path_exists(legacy_path).await? {
|
||||
fn migrate_raw_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).await? {
|
||||
remove_file_if_exists(legacy_path).await?;
|
||||
if path_exists(target_path)? {
|
||||
remove_file_if_exists(legacy_path)?;
|
||||
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?;
|
||||
let data = fs::read(legacy_path)?;
|
||||
write_bytes_atomically(target_path, &data)?;
|
||||
remove_file_if_exists(legacy_path)?;
|
||||
Ok(MigrationOutcome::Migrated)
|
||||
}
|
||||
|
||||
async fn migrate_empty_marker(
|
||||
fn migrate_empty_marker(
|
||||
legacy_path: &Path,
|
||||
target_path: &Path,
|
||||
) -> std::io::Result<MigrationOutcome> {
|
||||
if !path_exists(legacy_path).await? {
|
||||
if !path_exists(legacy_path)? {
|
||||
return Ok(MigrationOutcome::SourceMissing);
|
||||
}
|
||||
|
||||
if path_exists(target_path).await? {
|
||||
remove_file_if_exists(legacy_path).await?;
|
||||
if path_exists(target_path)? {
|
||||
remove_file_if_exists(legacy_path)?;
|
||||
return Ok(MigrationOutcome::TargetAlreadyExists);
|
||||
}
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
tokio::fs::File::create(target_path)
|
||||
.await?
|
||||
.sync_all()
|
||||
.await?;
|
||||
remove_file_if_exists(legacy_path).await?;
|
||||
fs::File::create(target_path)?.sync_all()?;
|
||||
remove_file_if_exists(legacy_path)?;
|
||||
Ok(MigrationOutcome::Migrated)
|
||||
}
|
||||
|
||||
async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
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?;
|
||||
let mut file = fs::File::create(&tmp_path)?;
|
||||
file.write_all(data)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, path).await?;
|
||||
fs::rename(&tmp_path, path)?;
|
||||
sync_parent_dir(path)
|
||||
}
|
||||
|
||||
@@ -374,16 +343,16 @@ fn library_index_tmp_path(path: &Path) -> PathBuf {
|
||||
path.with_file_name(tmp_name)
|
||||
}
|
||||
|
||||
async fn path_exists(path: &Path) -> std::io::Result<bool> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_if_exists(path: &Path) -> MigrationReport {
|
||||
match remove_file_if_exists(path).await {
|
||||
fn delete_if_exists(path: &Path) -> MigrationReport {
|
||||
match scoped_blocking(|| remove_file_if_exists(path)) {
|
||||
Ok(true) => MigrationReport {
|
||||
legacy_files_deleted: 1,
|
||||
..MigrationReport::default()
|
||||
@@ -399,75 +368,61 @@ async fn delete_if_exists(path: &Path) -> MigrationReport {
|
||||
}
|
||||
}
|
||||
|
||||
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? {
|
||||
fn remove_file_if_exists(path: &Path) -> std::io::Result<bool> {
|
||||
if !path_exists(path)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match tokio::fs::remove_file(path).await {
|
||||
match fs::remove_file(path) {
|
||||
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 {
|
||||
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()
|
||||
};
|
||||
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();
|
||||
}
|
||||
};
|
||||
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,
|
||||
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) {
|
||||
@@ -503,7 +458,13 @@ fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
install::intent::{InstallIntentState, read_intent},
|
||||
install::intent::{
|
||||
InstallIntent,
|
||||
InstallIntentState,
|
||||
LoadedInstallIntent,
|
||||
read_intent,
|
||||
write_intent,
|
||||
},
|
||||
test_support::TempDir,
|
||||
};
|
||||
|
||||
@@ -538,15 +499,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrates_per_game_intent_and_setup_marker() {
|
||||
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 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);
|
||||
@@ -554,7 +510,7 @@ mod tests {
|
||||
|
||||
write_file(
|
||||
&legacy_intent,
|
||||
&serde_json::to_vec_pretty(&intent).expect("intent should serialize"),
|
||||
br#"{"schema_version":1,"state":"Updating"}"#,
|
||||
);
|
||||
write_file(&legacy_tmp, b"tmp");
|
||||
write_file(&legacy_setup, b"");
|
||||
@@ -562,38 +518,58 @@ mod tests {
|
||||
|
||||
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||
|
||||
assert_eq!(report.install_intents_migrated, 1);
|
||||
assert_eq!(report.install_intents_migrated, 0);
|
||||
assert_eq!(report.failures, 2);
|
||||
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_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 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 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)
|
||||
.await
|
||||
.expect("app-state intent should be written");
|
||||
write_file(
|
||||
&legacy_intent_path,
|
||||
&serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"),
|
||||
br#"{"schema_version":1,"state":"Installing"}"#,
|
||||
);
|
||||
write_file(&setup_done_path(state.path(), "game"), b"");
|
||||
write_file(&legacy_setup, b"");
|
||||
@@ -601,11 +577,14 @@ mod tests {
|
||||
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 intent = read_intent(state.path(), "game").await;
|
||||
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_intent_path.exists());
|
||||
assert!(!legacy_setup.exists());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user